SwiftUI:仅在超过屏幕高度时才使 ScrollView 可滚动

SwiftUI: Make ScrollView scrollable only if it exceeds the height of the screen

目前我有一个看起来像这样的视图。

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

这会在滚动视图中呈现一个包含 3 个文本的视图,每当我在屏幕中拖动这些文本中的任何一个时,视图都会移动导致其可滚动,即使这 3 个文本适合屏幕并且还有剩余 space。我想要实现的是仅当 ScrollView 的内容超过屏幕高度时才使其可滚动,否则,我希望视图是静态的并且不移动。我试过使用 GeometryReader 并将滚动视图框架设置为屏幕宽度和高度,内容也相同,但我仍然有相同的行为,我也试过设置 minHeight、maxHeight,但没有任何运气。

我怎样才能做到这一点?

如果滚动视图的内容不需要用户交互(如 PO 问题),这是一种可能的方法:

测试 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: [=10=].frame(in: .local).size.height) })
            }
            .onPreferenceChange(ViewHeightKey.self) {
                 self.fitInScreen = [=10=] < gp.size.height    // << here !!
            }
            .disabled(self.fitInScreen)
        }
    }
}

注意: ViewHeightKey 偏好键取自

backup

以下解决方案允许您在内部使用 Button:

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: [=10=].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: [=10=].frame(in: .local).size.height)})
                }
                .onPreferenceChange(SpecialViewHeightKey.self) {
                     self.fitInScreen = [=10=] < 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
        }
    }
}

我为这个问题制作了一个更全面的组件,适用于所有类型的轴组:

代码

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 [=10=].height <= geometry.size.height {
                            axes.remove(.vertical)
                        }
                        if [=10=].width <= geometry.size.width {
                            axes.remove(.horizontal)
                        }
                    }
            }
        }
    }
}

private struct ContentSizeReader: View {
    
    var body: some View {
        GeometryReader {
            Color.clear
                .preference(
                    key: ContentSizeKey.self,
                    value: [=10=].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)
    }
}

用法

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

关注

此代码在这些情况下无法工作:

  1. 内容大小动态变化
  2. ScrollView 大小动态变化
  3. 设备方向改变

基于 Asperi 的回答,当我们知道内容将要溢出时,我们可以有条件地用 ScrollView 包装视图。这是您可以创建的视图扩展:

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

在主视图中,只需使用您的逻辑检查视图是否太长,也许使用 GeometryReader 和背景颜色技巧:

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

出于某种原因,我无法完成上述任何一项工作,但它确实激励我找到了适合我的情况的解决方案。它不像其他的那样灵活,但可以很容易地进行调整以支持滚动的两个轴。

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

用法

VStack {
   // Your content
}
.scrollOnOverflow()

我不能发表评论,因为我没有足够的声誉,但我想在 答案中添加评论。该扩展非常适合我,对于布尔值是否显示 scrollView,我使用此代码来了解设备的高度:

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

因此,使用此变量我可以判断设备高度是否小于 700,如果是,我想让视图可滚动以便内容可以毫无问题地显示。

所以在应用扩展时,我只是这样做:

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

我的解决方案不会禁用内容交互性

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

用法:

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

根据!回答,我创建了一个涵盖报告问题的自定义组件

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: [=10=].frame(in: .local).size.height) })
                
            }
            .onPreferenceChange(ViewHeightKey.self) {
                 self.fitInScreen = [=10=] < gp.size.height    // << here !!
            }
            
           
        }
        
    }
    
}

用法:

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