rustproofFish
rustproofFish

Reputation: 999

Dynamic row height in a SwiftUI form

I'm adding controls to a SwiftUI Form to assist the user enter data (and constrain the entries!). Although there is a lot to like about Forms, I've discovered that things that work nicely outside this container do very unexpected things inside it and it's not always obvious how to compensate for this.

The plan is to have the data field displayed as a single row. When the row is tapped, the control slides out from behind the data field - the row will need to expand (height) to accommodate the control.

I'm using Swift Playgrounds to develop the proof of concept (or failure in my case). The idea is to use a ZStack which will allow a nice sliding animation by overlaying the views and giving them a different zIndex and applying the offset when the data field view is tapped. Sounds simple but of course the Form row does not expand when the ZStack is expanded.

Adjusting the frame of the ZStack while expanding causes all sorts of weird changes in padding (or at least it looks like it) which can be compensated for by counter-offsetting the "top" view but this causes other unpredictable behaviour. Pointers and ideas gratefully accepted.

import 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 Collapsed stack

Expanded stack - view overlaps adjacent row Expanded stack - view overlaps adjacent row

Result when adjusting ZStack vertical frame when expanded - top padding increases Result when adjusting ZStack vertical frame when expanded - top padding increases

Upvotes: 3

Views: 7883

Answers (5)

rustproofFish
rustproofFish

Reputation: 999

Thanks to both Kyokook (for putting me straight on offset()) and Asperi.

I think the Kyokook's solution (using AlignmentGuides) is simpler and would be my preference in that it's leveraging Apple's existing API and seems to cause less unpredictable movement of the views in their container. However, the row height changes abruptly and isn't synchronised. The animation in the Asperi's example is smoother but there is some bouncing of the views within the row (it's almost as if the padding or insets are changing and then being reset at the end of the animation). My approach to animation is a bit hit-and-miss so any further comments would be welcome.

Solution 1 (frame consistent, animation choppy):

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

Solution 2 (smoother animation but frame variance):

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

Upvotes: 3

xTwisteDx
xTwisteDx

Reputation: 2472

I wanted to come and answer this, with a working "Production Ready" view, that will accomplish the goals set-out to create this animation. This solution solves the problem of the previous answers, where it doesn't handle dynamic header, and dynamic content. This can take any size header, and any size content, and still create the drawer effect. You should be aware that it "Resizes" the frame for the content, while it's "Behind" the header. If you're concerned about it not responding properly to resizes, then you'll need to implement some tricks in your content animations, but I left that out for brevity. Also, it should not be used in a List as it will create an unexpected bounce animation, as others have previously mentioned.

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

Usage of this view looks like this.

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

Upvotes: 0

Asperi
Asperi

Reputation: 257779

Here is possible solution with fluent row height change (using AnimatingCellHeight modifier taken from my solution in SwiftUI - Animations triggered inside a View that's in a list doesn't animate the list as well ).

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

Upvotes: 5

rustproofFish
rustproofFish

Reputation: 999

I now have a working implementation using alignment guides as suggested by Kyokook. I have softened the somewhat jarring row height change by adding an opacity animation to the Stepper as it slides out. This also helps to prevent a slightly glitchy overlap of the row title when the control is closed.

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

There is still some room for improvement here but on the whole I'm pleased with the result :-)

canvas preview

Upvotes: 2

Kyokook Hwang
Kyokook Hwang

Reputation: 2762

Use alignmentGuide instead of offset.

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

offset doesn't affect its view's frame. that's why Form doesn't react as expected. On the contrary, alignmentGuide does.

Upvotes: 3

Related Questions