34

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,557
  • 4
  • 18
  • 26
  • 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
  • 4
    @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

11 Answers11

32

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
  • 381
  • 3
  • 5
  • 1
    This is a much better solution than the accepted answer, as it works with views that have interactive elements. Thanks! – William Key Aug 17 '22 at 16:28
  • 5
    This did not work for me. The content never became scrollable. – Greg Nov 09 '22 at 19:17
  • Great, easy to use solution! I wrapped the `VStack` in a `Group` and applied the modifier there, because my `VStack` had padding, that I wanted to be inside the `ScrollView` and not applied to the `ScrollView` itself. So all in all: `Group { VStack { /* content */ }.padding() }.scrollOnOverflow()` – Codey Jul 27 '23 at 17:25
18

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

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 4
    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? – Leo Nov 21 '20 at 11:37
  • 1
    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
  • 4
    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
  • @Daniel Good job, useful package! – soroushamdg May 30 '23 at 22:27
  • @Daniel Amazing bro, ScrollViewIfNeeded help me so much ^^! – Huynh Inc Jul 14 '23 at 04:15
10

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.clear.onAppear() {
                    DispatchQueue.main.async {
                         size = geometry.size
                    }
                }
            }
        )
    }
}

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")
        }
    }
}
Laszlo
  • 2,803
  • 2
  • 28
  • 33
Nikaaner
  • 1,022
  • 16
  • 19
8

Post iOS 16.4: You can now use one of the new ScrollView modifiers like:

var body: some View {
   ScrollView {
      //your Content
   }
   .scrollBounceBehaviour(.basedOnSize, axes: .vertical)
}

See: https://developer.apple.com/documentation/charts/chart/scrollbouncebehavior(_:axes:)?changes=latest_major

Post iOS16: I'd have used the pattern matching nature of ViewThatFits:

var body: some View {
   ViewThatFits {
      //your Content
      ScrollView {
          //same Content
      }
   }
}
paescebu
  • 179
  • 2
  • 4
7

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
  • 3,251
  • 2
  • 17
  • 35
  • 1
    Hi https://stackoverflow.com/users/10700672/lorenzo-fiamingo or another developer :] just need help here. Is there a way please to make this solution responds dynamically for when turning on accessibility and increase the font size. I've tried all other solutions on this post and I think yours is the closest. If you turn on accessiblity and increase font to the max then start the app, it will create the scrollbar and will respond dynamically when switched off but not the other way around. Obviously we don't want to disable UI within the scrollView. Thank you. – Wael Aug 04 '22 at 02:06
5

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
  • 450
  • 5
  • 10
3

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)
    
    }
}
Alessandro Pace
  • 206
  • 4
  • 8
2

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()
    }
}
  • This solution is the only one that seems to work with on the fly dynamic type settings, while also not changing the layout when the content is not scrollable. – Jason Clardy Apr 21 '23 at 10:45
1

This might help in case you need to listen on changes in font sizes, context changes etc. Simply just change the viewIndex to you needed identifier for changes.

This view will inform you about if it's scrolled or not, and also if the original content fits inside the scrollview or if it's scrollable.

Hope it helps someone :)

import Combine
import SwiftUI

struct FeedbackScrollView<Content: View>: View {
    
    /// Used to inform the FeedbackScrollView if the view changes (mainly used in 'flows')
    var viewIndex: Double
    /// Notifies if the scrollview is scrolled
    @Binding var scrollViewIsScrolled: Bool
    /// Notifies if the scrollview has overflow in it's content, to indicate if it can scroll or now
    @Binding var scrollViewCanScroll: Bool
    /// The content you want to put into the scrollview.
    @ViewBuilder private let content: () -> Content
    
    public init(
        viewIndex: Double = 0,
        scrollViewIsScrolled: Binding<Bool> = .constant(false),
        scrollViewCanScroll: Binding<Bool>,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.viewIndex = viewIndex
        self._scrollViewIsScrolled = scrollViewIsScrolled
        self._scrollViewCanScroll = scrollViewCanScroll
        self.content = content
    }
    
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                offsetReader
                content()
                    .frame(
                        minHeight: geometry.size.height,
                        alignment: .topLeading
                    )
                    .background(
                        GeometryReader { contentGeometry in
                            Color.clear
                                .onAppear {
                                    scrollViewCanScroll = contentGeometry.size.height > geometry.size.height
                                }
                                .onChange(of: viewIndex) { _ in
                                    scrollViewCanScroll = contentGeometry.size.height > geometry.size.height
                                }
                        }
                    )
            }
            .dismissKeyboardOnDrag()
            .coordinateSpace(name: "scrollSpace")
            .onPreferenceChange(OffsetPreferenceKey.self, perform: offsetChanged(offset:))
        }
    }
    
    var offsetReader: some View {
        GeometryReader { proxy in
            Color.clear
                .preference(
                    key: OffsetPreferenceKey.self,
                    value: proxy.frame(in: .named("scrollSpace")).minY
                )
        }
        .frame(height: 0)
    }
    
    private func offsetChanged(offset: CGFloat) {
        withAnimation {
            scrollViewIsScrolled = offset < 0
        }
    }
}

private struct OffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

struct FeedbackScrollView_Previews: PreviewProvider {
    static var previews: some View {
        FeedbackScrollView(
            viewIndex: 0,
            scrollViewIsScrolled: .constant(false),
            scrollViewCanScroll: .constant(true)
        ) { }
    }
}

Use it like this:

...

@State var scrollViewIsScrolled: Bool
@State var scrollViewCanScroll: Bool

FeedbackScrollView(
   viewIndex: numberOfCompletedSteps,
   scrollViewIsScrolled: $scrollViewIsScrolled,
   scrollViewCanScroll: $scrollViewCanScroll
) {
    // Your (scrollable) content goes here..
}
Nicolai Harbo
  • 1,064
  • 12
  • 25
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,746
  • 3
  • 18
  • 30
0

Unfourtunatly none of the solutions here allow for dynamically responding to when turning on accessibility and increasing the font size on the fly. Hoping there will be a complete solution without disabling the UI within the scrollView.

Wael
  • 489
  • 6
  • 19