iosanimationswiftui

Keep two view positions synchronized during animation


I have a questions about syncing view positions during an animation change.

Here's the version I'm aiming for, but notice when the keyboard appears, the view height changes immediately with no animation.

working version no animations on shrinking list view height

This version is what I currently have, with the animation when view height shrinks, but notice on the swipe down there is a noticeable gap between the date header and the top of the list view. Kind of appears broken.

broken animation on swipe

Here's the code:

                        .onReceive(keyboardHeightPublisher.removeDuplicates()) { height in
                        withAnimation {
                            let unadjustedKeyboardHeight = self.keyboardUnadjustedHeight - height
                            self.keyboardAdjustedListHeight = unadjustedKeyboardHeight
                        } completion: {
                            switch (self.modalState) {
                            case .didShow:
                                self.modalState = .didShow
                            default:
                                break
                            }
                        }
                    }

So the self.keyboardAdjustedListHeight is attached to to the list view:

.frame(height: self.keyboardAdjustedListHeight)
                    .position(CGPoint(x: (geometry.size.width / 2), y: (self.gesturePosition.y + (self.dateHeaderRect.height / 2)) + (self.keyboardAdjustedListHeight / 2)))

The entire modal is is just

ZStack 
  - VStack (date header)
       - drag gesture
  - VStack (list view)
       - positioned under the date header based on drag gesture position.

I tried matchedGeometryEffect but that didn't do the trick.

Any clues?

EDIT: I'm aiming to get the second GIF (animated list view) but the only problem is the gap upon dragging down.

SOLUTION: Used a variation of @Benzy Neez's solution here.

enter image description here

This was the intended effect. Basically an animation of the list view shrinking but no separation of the date header from the list view on down drag. This code is beneath the list view VStack. So still:

ZStack 
    VStack (date view)
        (drag gesture updates position)
    VStack (list view)
        (updates position based on drag gesture)

            .animation(.easeInOut, value: self.animateShrinkModal)
                    .toolbar(content: {
                        ToolbarItem(placement: .keyboard) {
                            EmptyView()
                        }
                    })
                    .onReceive(keyboardHeightPublisher.removeDuplicates()) { height in
                        let unadjustedKeyboardHeight = self.keyboardUnadjustedHeight - height
                        self.keyboardAdjustedListHeight = unadjustedKeyboardHeight
                        
                        if height > .zero {
                            self.animateShrinkModal.toggle()
                        }
                        
                        switch (self.modalState) {
                        case .didShow:
                            self.modalState = .didShow
                        default:
                            break
                        }
                    }

As you can see I only trigger the animation when the keyboard height is not zero. The modal height is reset without an animation right after the keyboard height is zero which is well after the drag ends. Even dragging slowly toward the keyboard has the desired effect.


Solution

  • You explained in a comment that the main issue is that the background is becoming detached from the header during animation.

    As a fix, it might help to apply .geometryGroup() to the parent container.

    In the question you included a snippet that shows how you are setting the height and position of the list view. This should not be necessary, as demonstrated in the simple example below. However, if you really want to do it that way, it may help to add alignment: .top to the .frame modifier.

    struct ContentView: View {
        @State private var text = ""
        @State private var isExpanded = false
        @FocusState private var isFocused: Bool
    
        var body: some View {
            ZStack(alignment: .bottom) {
                VStack(alignment: .leading) {
                    Text("Page header")
                        .font(.largeTitle)
                    Text("Main content")
                        .padding(.bottom, 100)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
                .padding()
                .ignoresSafeArea(.keyboard)
    
                VStack(alignment: .leading) {
                    HStack {
                        Text("Header")
                        Spacer()
                        Image(systemName: "plus.square.fill")
                            .imageScale(.small)
                            .foregroundStyle(.gray)
                    }
                    .font(.title2)
                    .padding()
                    .background {
                        UnevenRoundedRectangle(topLeadingRadius: 10, topTrailingRadius: 10)
                            .fill(Color(white: 0.9))
                    }
                    .onTapGesture { isExpanded = true }
    
                    if isExpanded {
                        Form {
                            TextField("Note", text: $text)
                                .focused($isFocused)
                        }
                        .scrollDisabled(true)
                        .scrollContentBackground(.hidden)
                    }
                }
                .background {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(.blue)
                        .padding(.bottom, isExpanded ? 20 : -300)
                        .shadow(color: .init(white: 0.4), radius: 6, y: 1)
                }
                .padding(.top, 100)
                .geometryGroup()
                .animation(.easeInOut, value: isExpanded)
                .animation(.easeInOut, value: isFocused)
                .gesture(
                    DragGesture()
                        .onChanged { val in
                            if val.translation.height > 0 {
                                isFocused = false
                                isExpanded = false
                            }
                        }
                )
            }
        }
    }
    

    You will notice that easeInOut is being used for the animation, instead of .spring. This seems to stay more in sync with the keyboard movement.

    Animation