15

Currently I have a view that looks like this.

struct StatsView: View {
    var body: some View {
        ScrollView {
            Text("Test1")
            Text("Test2")
            Text("Test3")
        }
    }
}

This renders a view that contains 3 texts inside a scroll view, whenever I drag any of these texts in the screen the view will move cause its scrollable, even if these 3 texts fit in the screen and there is remaining space. What I want to achieve is to only make the ScrollView scrollable if its content exceeds the screen height size, if not, I want the view to be static and don't move. I've tried using GeometryReader and setting the scrollview frame to the screen width and height, also the same for the content but I continue to have the same behaviour, also I have tried setting the minHeight, maxHeight without any luck.

How can I achieve this?

ravelinx
  • 1,267
  • 3
  • 15
  • 22
  • 2
    I don't think scroll view scrolls if its content doesn't exceed screen height by default. from your description, I think it is bouncing (if it comes back to the initial position after leaving touch). Try setting scrollView.alwaysBounceHorizontal = false & scrollView.bounces = false and check if it works – Muhammad Ali Jun 19 '20 at 05:36
  • 3
    @MuhammadAli, this is about SwiftUI, which ScrollView does not have either `alwaysBounceHorizontal` or `bounces` like UIScrollView in UIKit, so be attentive next time before commenting so categorical. – Asperi Jun 19 '20 at 08:37
  • @Asperi At least MuhammedAli pointed out this is bouncing behavior. As such this sounds a bit like a duplicate for [how to disable scrollview bounce in swiftui](https://stackoverflow.com/questions/58799474/how-to-disable-scrollview-bounce-in-swiftui). The accepted answer there has some flaws, so you may want to add your answer there as well. – Jack Goossen Jun 19 '20 at 08:49

8 Answers8

17

Here is a possible approach if a content of scroll view does not require user interaction (as in PO question):

Tested with Xcode 11.4 / iOS 13.4

struct StatsView: View {
    @State private var fitInScreen = false
    var body: some View {
        GeometryReader { gp in
            ScrollView {
                VStack {          // container to calculate total height
                    Text("Test1")
                    Text("Test2")
                    Text("Test3")
                    //ForEach(0..<50) { _ in Text("Test") } // uncomment for test
                }
                .background(GeometryReader {
                    // calculate height by consumed background and store in 
                    // view preference
                    Color.clear.preference(key: ViewHeightKey.self,
                        value: $0.frame(in: .local).size.height) })
            }
            .onPreferenceChange(ViewHeightKey.self) {
                 self.fitInScreen = $0 < gp.size.height    // << here !!
            }
            .disabled(self.fitInScreen)
        }
    }
}

Note: ViewHeightKey preference key is taken from this my solution

backup

Asperi
  • 173,274
  • 14
  • 284
  • 455
  • 3
    It seems that disabling the ScrollView also disables all other interactive elements within the ScrollView. Is there a modifier which only disables the scroll capabilities without effecting the content of the ScrollView? – lenny Nov 21 '20 at 11:37
  • With SwiftUI, are we now starting to abuse API methods in iOS development to find visual solutions? In this "solution" it should be clearly mentioned that `disabled` disables all controls contained in it. This affects both user interaction and styling (e.g. if there's a Button nested). A solution with GeometryReader, Conditional Views and View Content would be the proper way, imo. – Frederik Winkelsdorf Feb 04 '21 at 07:45
  • @Ienny please check my post – Simone Pistecchia Mar 13 '21 at 10:26
  • Made it a bit more generic and packet it into this GitHub project: [ScrollViewIfNeeded](https://github.com/dkk/ScrollViewIfNeeded) – Daniel Dec 03 '21 at 11:20
16

For some reason I could not make work any of the above, but it did inspire me find a solution that did in my case. It's not as flexible as others, but could easily be adapted to support both axes of scrolling.

import SwiftUI

struct OverflowContentViewModifier: ViewModifier {
    @State private var contentOverflow: Bool = false
    
    func body(content: Content) -> some View {
        GeometryReader { geometry in
            content
            .background(
                GeometryReader { contentGeometry in
                    Color.clear.onAppear {
                        contentOverflow = contentGeometry.size.height > geometry.size.height
                    }
                }
            )
            .wrappedInScrollView(when: contentOverflow)
        }
    }
}

extension View {
    @ViewBuilder
    func wrappedInScrollView(when condition: Bool) -> some View {
        if condition {
            ScrollView {
                self
            }
        } else {
            self
        }
    }
}

extension View {
    func scrollOnOverflow() -> some View {
        modifier(OverflowContentViewModifier())
    }
}

Usage

VStack {
   // Your content
}
.scrollOnOverflow()
user16401900
  • 181
  • 1
  • 4
2

I've made a more comprehensive component for this problem, that works with all type of axis sets:

Code

struct OverflowScrollView<Content>: View where Content : View {
    
    @State private var axes: Axis.Set
    
    private let showsIndicator: Bool
    
    private let content: Content
    
    init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, @ViewBuilder content: @escaping () -> Content) {
        self._axes = .init(wrappedValue: axes)
        self.showsIndicator = showsIndicators
        self.content = content()
    }

    fileprivate init(scrollView: ScrollView<Content>) {
        self._axes = .init(wrappedValue: scrollView.axes)
        self.showsIndicator = scrollView.showsIndicators
        self.content = scrollView.content
    }

    public var body: some View {
        GeometryReader { geometry in
            ScrollView(axes, showsIndicators: showsIndicator) {
                content
                    .background(ContentSizeReader())
                    .onPreferenceChange(ContentSizeKey.self) {
                        if $0.height <= geometry.size.height {
                            axes.remove(.vertical)
                        }
                        if $0.width <= geometry.size.width {
                            axes.remove(.horizontal)
                        }
                    }
            }
        }
    }
}

private struct ContentSizeReader: View {
    
    var body: some View {
        GeometryReader {
            Color.clear
                .preference(
                    key: ContentSizeKey.self,
                    value: $0.frame(in: .local).size
                )
        }
    }
}

private struct ContentSizeKey: PreferenceKey {
    static var defaultValue: CGSize { .zero }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = CGSize(width: value.width+nextValue().width,
                       height: value.height+nextValue().height)
    }
}

// MARK: - Implementation

extension ScrollView {
    
    public func scrollOnlyOnOverflow() -> some View {
        OverflowScrollView(scrollView: self)
    }
}

Usage

ScrollView([.vertical, .horizontal]) {
    Text("Ciao")
}
.scrollOnlyOnOverflow()

Attention

This code could not work in those situations:

  1. Content size change dynamically
  2. ScrollView size change dynamically
  3. Device orientation change
Lorenzo Fiamingo
  • 2,106
  • 2
  • 10
  • 29
2

Building on Asperi's answer, we can conditionally wrap the view with a ScrollView when we know the content is going to overflow. This is an extension to View you can create:

extension View {
  func useScrollView(
    when condition: Bool,
    showsIndicators: Bool = true
  ) -> AnyView {
    if condition {
      return AnyView(
        ScrollView(showsIndicators: showsIndicators) {
          self
        }
      )
    } else {
      return AnyView(self)
    }
  }
}

and in the main view, just check if the view is too long using your logic, perhaps with GeometryReader and the background color trick:

struct StatsView: View {
    var body: some View {
            VStack {
                Text("Test1")
                Text("Test2")
                Text("Test3")
            }
            .useScrollView(when: <an expression you write to decide if the view fits, maybe using GeometryReader>)
        }
    }
}
happymacaron
  • 176
  • 2
  • 5
2

I can't comment, because I don't have enough reputation, but I wanted to add a comment in the happymacaron answer. The extension worked for me perfectly, and for the Boolean to show or not the scrollView, I used the this code to know the height of the device:

///Device screen
var screenDontFitInDevice: Bool {
    UIScreen.main.bounds.size.height < 700 ? true : false
}

So, with this var I can tell if the device height is less than 700, and if its true I want to make the view scrollable so the content can show without any problem.

So wen applying the extension I just do this:

struct ForgotPasswordView: View {
    var body: some View {
        VStack {
            Text("Scrollable == \(viewModel.screenDontFitInDevice)")
        }
        .useScrollView(when: viewModel.screenDontFitInDevice, showsIndicators: false)
    
    }
}
2

My solution does not disable content interactivity

struct ScrollViewIfNeeded<Content: View>: View {
    @ViewBuilder let content: () -> Content

    @State private var scrollViewSize: CGSize = .zero
    @State private var contentSize: CGSize = .zero

    var body: some View {
        ScrollView(shouldScroll ? [.vertical] : []) {
            content().readSize($contentSize)
        }
        .readSize($scrollViewSize)
    }

    private var shouldScroll: Bool {
        scrollViewSize.height <= contentSize.height
    }
}

struct SizeReaderModifier: ViewModifier  {
    @Binding var size: CGSize
    
    func body(content: Content) -> some View {
        content.background(
            GeometryReader { geometry -> Color in
                DispatchQueue.main.async {
                    size = geometry.size
                }
                return Color.clear
            }
        )
    }
}

extension View {
    func readSize(_ size: Binding<CGSize>) -> some View {
        self.modifier(SizeReaderModifier(size: size))
    }
}

Usage:

struct StatsView: View {
    var body: some View {
        ScrollViewIfNeeded {
            Text("Test1")
            Text("Test2")
            Text("Test3")
        }
    }
}
Nikaaner
  • 718
  • 10
  • 13
  • This caused the view to act erratically for me when `shouldScroll` is false. I fixed it by moving the DispatchQueue.main.async block inside an `.onAppear` modifier on `Color.clear`. – johnpenning Apr 28 '22 at 00:38
0

The following solution allows you to use Button inside:

Based on @Asperi solution

SpecialScrollView:

/// Scrollview disabled if smaller then content view
public struct SpecialScrollView<Content> : View where Content : View {

    let content: Content

    @State private var fitInScreen = false

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    public var body: some View {
        if fitInScreen == true {
            ZStack (alignment: .topLeading) {
                content
                    .background(GeometryReader {
                                    Color.clear.preference(key: SpecialViewHeightKey.self,
                                                           value: $0.frame(in: .local).size.height)})
                    .fixedSize()
                Rectangle()
                    .foregroundColor(.clear)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
            }
        }
        else {
            GeometryReader { gp in
                ScrollView {
                    content
                        .background(GeometryReader {
                                        Color.clear.preference(key: SpecialViewHeightKey.self,
                                                               value: $0.frame(in: .local).size.height)})
                }
                .onPreferenceChange(SpecialViewHeightKey.self) {
                     self.fitInScreen = $0 < gp.size.height
                }
            }
        }
    }
}

struct SpecialViewHeightKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = value + nextValue()
    }
}

USE:

struct SwiftUIView6: View {
        
@State private var fitInScreen = false
    var body: some View {
        
        VStack {
            Text("\(fitInScreen ? "true":"false")")
            SpecialScrollView {
                ExtractedView()
            }
        }
    }
}



struct SwiftUIView6_Previews: PreviewProvider {
    static var previews: some View {
        SwiftUIView6()
    }
}

struct ExtractedView: View {
    @State var text:String = "Text"
    var body: some View {
        VStack {          // container to calculate total height
            Text(text)
                .onTapGesture {text = text == "TextModified" ? "Text":"TextModified"}
            Text(text)
                .onTapGesture {text = text == "TextModified" ? "Text":"TextModified"}
            Text(text)
                .onTapGesture {text = text == "TextModified" ? "Text":"TextModified"}
            Spacer()
            //ForEach(0..<50) { _ in Text(text).onTapGesture {text = text == "TextModified" ? "Text":"TextModified"} } // uncomment for test
        }
    }
}
Simone Pistecchia
  • 2,383
  • 3
  • 15
  • 24
0

According to the Asperi! answer, I created a custom component that covers reported issue

private struct ViewHeightKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = value + nextValue()
    }
}

struct SmartScrollView<Content: View>: View {
    @State private var fitInScreen = false
    @State var axes = Axis.Set.vertical
    
    let content: () -> Content
    
    var body: some View {
        GeometryReader { gp in
            ScrollView(axes) {
                content()
                    .onAppear {
                        axes = fitInScreen ? [] : .vertical
                    }
                    
                .background(GeometryReader {
                    // calculate height by consumed background and store in
                    // view preference
                    Color.clear.preference(key: ViewHeightKey.self,
                        value: $0.frame(in: .local).size.height) })
                
            }
            .onPreferenceChange(ViewHeightKey.self) {
                 self.fitInScreen = $0 < gp.size.height    // << here !!
            }
            
           
        }
        
    }
    
}

usage:

var body: some View {
    SmartScrollView {
        Content()
    }
}