ravelinx
ravelinx

Reputation: 1785

Make ScrollView scrollable only if it exceeds the height of the window

Currently I have a view 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 inside of the window, the view will move because it's scrollable, even if these 3 texts fit in the window and there is remaining space.
What I want to achieve is to only make the ScrollView scrollable if its content exceeds the window's height. If not, I want the view to be static and not move.

I've tried to use GeometryReader and setting the scroll view's frame to the window width and height, also the same for the content but I continue to have the same behavior. I have also tried setting minHeight and maxHeight without any luck.

Upvotes: 54

Views: 43018

Answers (12)

Asperi
Asperi

Reputation: 258441

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)              // for iOS <16
            //.scrollDisabled(self.fitInScreen)      // for iOS 16+
        }
    }
}

Note: ViewHeightKey preference key is taken from this my solution

Upvotes: 25

JonyMateos
JonyMateos

Reputation: 681

This might help in case scrolling needs to be disabled depending on some condition. It's possible from iOS 16 onwards:

var body: some View {
    ScrollView {
        // Content
    }
    .scrollDisabled(yourFlag)
}

Upvotes: -1

Nikaaner
Nikaaner

Reputation: 1320

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 in
                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")
        }
    }
}

Upvotes: 14

paescebu
paescebu

Reputation: 899

Post iOS 16.4: You can now use the scrollBounceBehavior(_:axes:) modifier:

var body: some View {
   ScrollView {
      // your content
   }
   .scrollBounceBehavior(.basedOnSize, axes: [.vertical])
}

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

var body: some View {
   ViewThatFits {
      // your content
      ScrollView {
          // same content
      }
   }
}

Upvotes: 50

Nicolai Harbo
Nicolai Harbo

Reputation: 1169

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..
}

Upvotes: 1

Wael
Wael

Reputation: 573

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.

Upvotes: 0

Mohammad Bashtani
Mohammad Bashtani

Reputation: 89

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()
    }
}

Upvotes: 2

Alessandro Pace
Alessandro Pace

Reputation: 264

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)
    
    }
}

Upvotes: 3

user16401900
user16401900

Reputation: 469

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()

Upvotes: 40

happymacaron
happymacaron

Reputation: 490

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>)
        }
    }
}

Upvotes: 5

Lorenzo Fiamingo
Lorenzo Fiamingo

Reputation: 4109

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

Upvotes: 8

Simone Pistecchia
Simone Pistecchia

Reputation: 2840

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
        }
    }
}

Upvotes: 0

Related Questions