Toni Granados

Toni Granados

Pull Down Drawer in SwiftUI


I’m exploring how far can we go using SwiftUI to build components that follows the directives set by the great Designing Fluid Interfaces presentation from WWDC18 (if you haven’t seen it, go ahead, it as relevant now as it was then).

My first experiment is this drawer or sheet that pulls down from the top.

It’s very simple, yet feels great to the touch.

The full view looks like this:

import SwiftUI

struct PullDownDrawer: View {
    // Define constants for different heights and the threshold for state change
    let closedHeight: CGFloat = 100
    let openHeight: CGFloat = 500
    let stateChangeThreshold = 0.3

    // Track the current height and the height when gesture is inactive
    @State private var currentHeight: CGFloat = 100
    @State private var idleHeight: CGFloat = 100

    // Calculate the normalized height (0 to 1)
    private var normalizedHeight: CGFloat { (currentHeight - closedHeight) / (openHeight - closedHeight) }
    private var isOpen: Bool { idleHeight == openHeight }

    // Track if the state change will occur during the gesture
    var wouldChangeState: Bool {
        // Dragging up when it's open
        (isOpen && normalizedHeight < 1 - stateChangeThreshold) ||
        // Dragging down when it's closed
        (!isOpen && normalizedHeight > stateChangeThreshold) ||
        // The drawer can also be closed when dragging past the maximum height, kinda like closing a blind
        (isOpen && normalizedHeight > 1 + stateChangeThreshold)
    }
    @State private var willChangeState = false

    // Define the drag gesture
    var gesture: some Gesture {
        DragGesture()
            .onChanged { value in
                let newHeight = idleHeight + value.translation.height

                if newHeight > openHeight {
                    // When dragging the drawer lower than the maximum height, simulate an increasing drag by reducing
                    // the extra height that we are applying with the drag
                    currentHeight = min(openHeight + pow(abs(newHeight - openHeight), 0.9), openHeight * 1.5)
                } else if newHeight < closedHeight {
                    // Similarly, increase drag when trying dragging below the minimum height
                    currentHeight = max(closedHeight - pow(abs(newHeight + closedHeight), 0.6), closedHeight / 1.65)
                } else {
                    currentHeight = newHeight
                }

                // Generate feedback when passing the state change threshold. The willChangeState flag is used to prevent
                // feedback from being generated multiple times.
                if wouldChangeState {
                    if !willChangeState {
                        UIImpactFeedbackGenerator(style: .light).impactOccurred()
                        willChangeState = true
                    }
                } else {
                    willChangeState = false
                }
            }
            .onEnded { value in
                // Using a bouncy animation, just change the height to the open or close height depending on the current
                // state and whether wouldChangeState is true. If changing state, we generate a heavier feedback that
                // feels great next to the lighter one used to indicate that the drawer were going to change state when
                // releasing the gesture, this one feels like a confirmation of the action
                withAnimation(.bouncy) {
                    if wouldChangeState {
                        currentHeight = isOpen ? closedHeight : openHeight
                        UIImpactFeedbackGenerator(style: .medium).impactOccurred()
                    } else {
                        currentHeight = isOpen ? openHeight : closedHeight
                    }

                    idleHeight = currentHeight
                }
            }
    }

    var body: some View {
        BackgroundView()
            .overlay(alignment: .top) {
                RoundedRectangle(cornerRadius: 18)
                    .fill(.ultraThinMaterial)
                    .ignoresSafeArea(edges: .top)
                    .frame(height: currentHeight)

                    .preferredColorScheme(.dark)
                    .gesture(gesture)
            }
    }
}

Everything is annotated in the code, but as a summary, what we are doing here is taking a DragGesture and use it’s translation.height to update the height of the drawer, which is just a view overlayed over a the background view.

What makes it feels fluid and great to the touch is a combination of:

  • Being an interactive animation that you can control with your fingers
  • The haptic feedback that gives you an indication of what’s happening
  • The spring animation

Overall, I’m a bit surprised how easy this was to make and I’m eager to keep exploring new interactions based on the Designing Fluid Interfaces presentation.

PS: The BackgroundView with the animated gradients used in this example is made using a Metal shader. Apple introduced a simpler way to use Metal shaders within SwiftUI. I’m exploring shaders right now as well and I might write a post about that in the future.