Search code examples
iosswiftuiswiftui-sheetdetent

Animating content height when changing SwiftUI sheet detent programmatically


I'm encountering the following issue:

When a sheet with multiple detents is displayed:

  • resizing the sheet from the drag indicator will change the displayed view height accordingly and progressively
  • resizing the sheet by changing the detent programmatically will change the displayed view to it's final height instantly

How can I get a progressive resizing of the view presented when changing the detend programmatically?

Here is a code example and video to demonstrate

struct TestView: View {
    @State var selectedDetent: PresentationDetent = .medium
    @State var detents: Set<PresentationDetent> = [.large, .medium]
    @State var height: CGFloat = 0

    var body: some View {
        VStack {}
            .sheet(isPresented: .constant(true)) {
                Button {
                    selectedDetent = selectedDetent == .large ? .medium : .large
                } label: {
                    Text("\(Int(height))")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
                .padding()
                .buttonStyle(.borderedProminent)
                .modifier(GetHeightModifier(height: $height))
                .presentationDetents(detents, selection: $selectedDetent)
                .interactiveDismissDisabled()
            }
    }
}

struct GetHeightModifier: ViewModifier {
    @Binding var height: CGFloat

    func body(content: Content) -> some View {
        content.background(
            GeometryReader { geo -> Color in
                DispatchQueue.main.async {
                    height = geo.size.height
                }
                return Color.clear
            }
        )
    }
}
Dragging the sheet Changing detent programmatically
enter image description here enter image description here

Solution

  • The change can be made more animated by adding a .transaction modifier to the Button:

    Button {
        // ...
    } label: {
        // ...
    }
    .transaction { trans in
        trans.disablesAnimations = false
        trans.animation = .easeInOut(duration: 1)
    }
    // ... + other modifiers, as before
    

    It doesn't give the most sophisticated animation though:

    Animation


    EDIT Regarding your comment:

    that is not the expected result (the label is still jumpy), and it induces some regressions (when using the drag indicator)

    A similar animation can be achieved by tracking the height of the sheet and then applying this to the content, with animation.

    • An easy way to measure the size of the sheet is to use a GeometryReader to wrap the sheet content.
    • The content inside the GeometryReader can then use the sheet size directly, without having to go via a state variable.

    To avoid what you described as the regression problem when re-sizing with the drag indicator, the change to the content height only needs to be animated when the change is applied programmatically (with a button press).

    • An optional can be used for the content height.
    • When set to nil, changes are not animated. This is the default state, so it applies for size changes using the drag indicator.
    • When the button is pressed, the content height is set to the current sheet height, in preparation for an animated change.
    • An .onChange handler is used to detect the change of sheet height and this is used to update the content height, with animation.
    • The optional is reset to nil once the animations have completed. This is done after a short delay, because multiple .onChange updates are triggered by the native animation.
    struct TestView: View {
        let detents: Set<PresentationDetent> = [.large, .medium]
        @State var selectedDetent: PresentationDetent = .medium
        @State var contentHeight: CGFloat?
    
        var body: some View {
            VStack {}
                .sheet(isPresented: .constant(true)) {
                    GeometryReader { proxy in
                        let sheetHeight = proxy.size.height
                        Button {
                            contentHeight = sheetHeight
                            selectedDetent = selectedDetent == .large ? .medium : .large
                            Task { @MainActor in
                                try? await Task.sleep(for: .seconds(1))
                                contentHeight = nil
                            }
                        } label: {
                            Text("\(Int(sheetHeight))")
                                .frame(maxWidth: .infinity, maxHeight: .infinity)
                        }
                        .buttonStyle(.borderedProminent)
                        .padding()
                        .frame(height: contentHeight)
                        .onChange(of: sheetHeight) { oldVal, newVal in
                            if contentHeight != nil {
                                withAnimation(.spring(duration: 0.5)) {
                                    contentHeight = newVal
                                }
                            }
                        }
                    }
                    .presentationDetents(detents, selection: $selectedDetent)
                    .interactiveDismissDisabled()
                }
        }
    }
    

    With this approach, the animation is smoother and the label no longer jumps. However, the animation for a programmatic change still lags the change in sheet height:

    Animation