SwiftUI 表单中的动态行高

问题描述 投票:0回答:5

我正在向 SwiftUI 表单添加控件以帮助用户输入数据(并限制输入!)。尽管 Forms 有很多值得喜欢的地方,但我发现在这个容器之外运行良好的东西在容器内会做非常意想不到的事情,并且如何弥补这一点并不总是显而易见的。

计划是将数据字段显示为单行。点击该行时,控件从数据字段后面滑出 - 该行需要扩展(高度)以容纳控件。

我正在使用 Swift Playgrounds 来开发概念验证(或者在我的例子中失败)。这个想法是使用 ZStack,它可以通过覆盖视图并给它们一个不同的 zIndex 并在点击数据字段视图时应用偏移量来实现漂亮的滑动动画。听起来很简单,但是当 ZStack 展开时,Form 行当然不会展开。

在扩展时调整 ZStack 的框架会导致填充发生各种奇怪的变化(或者至少看起来像这样),这可以通过抵消“顶部”视图来补偿,但这会导致其他不可预测的行为。非常感谢您的指点和想法。

导入 SwiftUI

struct MyView: View {
    @State var isDisclosed = false

    var body: some View {
        Form { 
            Spacer()

            VStack { 
                ZStack(alignment: .topLeading) {
                    Rectangle()
                        .fill(Color.red)
                        .frame(width: 100, height: 100)
                        .zIndex(1)
                        .onTapGesture { self.isDisclosed.toggle() }

                    Rectangle()
                        .fill(Color.blue)
                        .frame(width: 100, height: 100)
                        .offset(y: isDisclosed ? 50 : 0)
                        .animation(.easeOut)
                }
            }

            Spacer()
        }
    }
}

折叠堆栈 Collapsed stack

扩展堆栈 - 视图与相邻行重叠 Expanded stack - view overlaps adjacent row

展开时调整 ZStack 垂直框架时的结果 - 顶部填充增加 Result when adjusting ZStack vertical frame when expanded - top padding increases

forms layout swiftui
5个回答
5
投票

这是行高变化流畅的可能解决方案(使用取自我在

SwiftUI 中的解决方案的 
AnimatingCellHeight 修饰符 - 在列表中的视图内触发的动画也不会为列表设置动画)。

使用 Xcode 11.4 / iOS 13.4 进行测试

demo

struct MyView: View {
    @State var isDisclosed = false

    var body: some View {
        Form {
            Spacer()

            ZStack(alignment: .topLeading) {
                Rectangle()
                    .fill(Color.red)
                    .frame(width: 100, height: 100)
                    .zIndex(1)
                    .onTapGesture { withAnimation { self.isDisclosed.toggle() } }

                HStack {
                    Rectangle()
                        .fill(Color.blue)
                        .frame(width: 100, height: 100)
                }.frame(maxHeight: .infinity, alignment: .bottom)
            }
            .modifier(AnimatingCellHeight(height: isDisclosed ? 150 : 100))

            Spacer()
        }
    }
}

3
投票

使用

alignmentGuide
代替
offset

...
//.offset(y: isDisclosed ? 50 : 0)
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 50 : 0) })
...

offset
不影响其视图的框架。这就是为什么 Form 没有按预期做出反应的原因。相反,
alignmentGuide
确实如此。


3
投票

感谢Kyokook(让我直接使用offset())和Asperi

我认为 Kyokook 的解决方案(使用 AlignmentGuides)更简单,并且是我的偏好,因为它利用 Apple 现有的 API,并且似乎会导致容器中视图的移动不太不可预测。但是,行高突然变化并且不同步。 Asperi 示例中的动画更平滑,但行内的视图有一些弹跳(几乎就像填充或插图正在更改,然后在动画结束时重置)。我的动画方法有点随意,所以欢迎任何进一步的评论。

解决方案1(帧一致,动画断断续续):

struct ContentView: View {
@State var isDisclosed = false

var body: some View {
    Form {
        Text("Row 1")
        
        VStack {
            ZStack(alignment: .topLeading) {
                Rectangle()
                    .fill(Color.red)
                    .frame(width: 100, height: 100)
                    .zIndex(1)
                    .onTapGesture {
                        self.isDisclosed.toggle()
                }
                
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: 100, height: 100)
                    .alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 100 : 0) })
                    .animation(.easeOut)
                
                
                Text("Row 3")
            }
        }
        
        Text("Row 3")
        
    }
}
}

solution 1

解决方案2(动画更平滑,但帧差异):

struct ContentView: View {
    @State var isDisclosed = false
    
    var body: some View {
        Form {
            Text("Row 1")
            
            VStack {
                ZStack(alignment: .topLeading) {
                    Rectangle()
                        .fill(Color.red)
                        .frame(width: 100, height: 100)
                        .zIndex(1)
                        .onTapGesture {
                            withAnimation { self.isDisclosed.toggle() }
                    }
                    

                    HStack {
                        Rectangle()
                            .fill(Color.blue)
                            .frame(width: 100, height: 100)
                    }.frame(maxHeight: .infinity, alignment: .bottom)
                }
                .modifier(AnimatingCellHeight(height: isDisclosed ? 200 : 100))
            }
            
            Text("Row 3")
        }
    }
}

struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0

var animatableData: CGFloat {
    get { height }
    set { height = newValue }
}

func body(content: Content) -> some View {
    content.frame(height: height)
}

}

solution 2


2
投票

我现在有了一个使用对齐指南的有效实施,如 Kyokook 所建议的。我通过在步进器滑出时添加不透明度动画来柔化有些刺耳的行高变化。这也有助于防止在关闭控件时行标题出现轻微的重叠。

struct ContentView: View {
// MARK: Logic state
@State private var years = 0
@State private var months = 0
@State private var weeks = 0

// MARK: UI state
@State var isStepperVisible = false

var body: some View {
    Form {
        Text("Row 1")
        
        VStack {
            // alignment guide must be explicit for the ZStack & all child ZStacks
            // must use the same alignment guide - weird stuff happens otherwise
            ZStack(alignment: .top) {
                HStack {
                    Text("AGE")
                        .bold()
                        .font(.footnote)
                    
                    Spacer()
                    
                    Text("\(years) years \(months) months \(weeks) weeks")
                        .foregroundColor(self.isStepperVisible ? Color.blue : Color.gray)
                }
                .frame(height: 35) // TODO: Without this, text in HStack vertically offset. Investigate. (HStack align doesn't help)
                .background(Color.white) // Prevents overlap of text during transition
                .zIndex(3)
                .contentShape(Rectangle())
                .onTapGesture {
                        self.isStepperVisible.toggle()
                }
                
                
                HStack(alignment: .center) {
                    StepperComponent(value: $years, label: "Years", bounds: 0...30, isVisible: $isStepperVisible)
                    StepperComponent(value: $months, label: "Months", bounds: 0...12, isVisible: $isStepperVisible)
                    StepperComponent(value: $weeks, label: "Weeks", bounds: 0...4, isVisible: $isStepperVisible)
                }
                .alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isStepperVisible ? 40 : 0) })
            }
        }
        
        Text("Row 3")
        
    }
}
}

struct StepperComponent<V: Strideable>: View {
// MARK: Logic state
@Binding var value: V
var label: String
var bounds: ClosedRange<V>
//MARK: UI state
@Binding var isVisible: Bool

var body: some View {
    ZStack(alignment: .top) {
        Text(label.uppercased()).font(.caption).bold()
            .frame(alignment: .center)
            .zIndex(1)
            .opacity(self.isVisible ? 1 : 0)
            .animation(.easeOut)
        
        Stepper(label, value: self.$value, in: bounds)
            .labelsHidden()
            .alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isVisible ? 25 : 0) })
            .frame(alignment: .center)
            .zIndex(2)
            .opacity(self.isVisible ? 1 : 0)
            .animation(.easeOut)
    }
    
}
}

这里还有一些改进的空间,但总的来说我对结果很满意:-)

canvas preview


0
投票

我想用一个有效的“制作就绪”视图来回答这个问题,这将实现创建此动画的目标。此解决方案解决了先前答案的问题,即它不处理动态标头和动态内容。这可以采用任何大小的标题和任何大小的内容,并且仍然创建抽屉效果。您应该知道,它“调整”内容框架的大小,而它位于标题“后面”。如果您担心它无法正确响应调整大小,那么您需要在内容动画中实现一些技巧,但为了简洁起见,我将其省略。另外,它不应该用在

List
中,因为它会创建意外的弹跳动画,正如其他人之前提到的那样。

struct DrawerView<Header: View, Content: View>: View {
    @State var isDisclosed = false
    @State var headerHeight: CGFloat = .zero
    
    let header: Header
    let content: Content
    
    var body: some View {
        VStack {
            ZStack(alignment: .top) {
                header
                    .getHeight(height: $headerHeight)
                    .frame(maxWidth: .infinity)
                    .zIndex(1)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        withAnimation {
                            isDisclosed.toggle()
                        }
                    }
                    .background(Color(uiColor: UIColor.systemBackground))
                
                content
                    .frame(maxHeight: isDisclosed ? nil : headerHeight)
                    .alignmentGuide(.top, computeValue: { d in
                        d[.top] - (isDisclosed ? headerHeight : 0)
                    })
            }
        }
        .clipped()
        .animation(.easeInOut, value: isDisclosed)
    }
    
    init(@ViewBuilder header: () -> Header, @ViewBuilder content: () -> Content) {
        self.header = header()
        self.content = content()
    }
}

此视图的用法如下所示。

#Preview {
    DrawerView(header: {
        ZStack {
            Rectangle()
                .fill(.red)
                .frame(height: 50)
            
            Text("Header")
        }
    }, content: {
        VStack {
            Text("Some Other View")
            Text("Some Other View")
            Text("Some Other View")
        }
    })
}
© www.soinside.com 2019 - 2024. All rights reserved.