3

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

5 Answers 5

5

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()
        }
    }
}
Sign up to request clarification or add additional context in comments.

1 Comment

This is the only solution which animates expanding the row as well (not only the Rectangle). Nice one @Asperi.
3

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.

Comments

3

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

3 Comments

That's good summary. If you want to go with Asperi way without the unexpected bouncing, In my knowledge unfortunately, you should use ScrollView + VStack instead of Form. In other words, full customizing sadly. you can get the effect you want with that approach.
So there’s no way of smoothing out the row height change if I use AlignmentGuides? I’d rather use your implementation if possible as it seems simpler to my mind - with the other approach, further work is required to sort the bounce out. I think perhaps I might have to Be pragmatic and accept it’s not worth the extra code for cosmetics ;-)
How do you modify approach 2 if you want the blue rectangle to be, say, 200 height instead of 100?
2

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

Comments

0

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

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.