Building a Custom Transition to Scale a View on One Axis

I’m building an app that has a mix of project management and todo list capabilities. For the design I took some inspiration from Things 3 and Todoist and I really liked how Things 3 revealed text input when you click on a todo item. Trying to figure out how to accomplish the transition led me to learn a bit about animations and transitions with SwiftUI.

I tried out several of the transitions like .offset and .scale but they didn’t quite have the behavior I was looking for. The scale transition was the closest to the behavior I wanted but it doesn’t allow scaling the view on only one axis. I only wanted the view to scale on the y direction downwards. I first took to Kagi search to see if someone had written about this particular transition, but I couldn’t find anything. Eventually, I asked Gemini how to do this transition and it came up with this code. .

// We use a near-zero value for the start to avoid division-by-zero issues.
struct VerticalScaleModifier: ViewModifier {
    var scaleY: CGFloat

    func body(content: Content) -> some View {
        content.scaleEffect(x: 1, y: scaleY, anchor: .top)
    }
}

// 2. Create a static extension on AnyTransition to make our new transition reusable.
extension AnyTransition {
    static var growFromTop: AnyTransition {
        .modifier(
            // `active` is the state during the transition (view is appearing)
            active: VerticalScaleModifier(scaleY: 0.00001),

            // `identity` is the final state (view is fully visible)
            identity: VerticalScaleModifier(scaleY: 1)
        )
        .combined(with: .opacity) // Adding opacity makes it look smoother
    }
}

The result

This code worked pretty well and, with some modifications to the animation speed, I was pretty satisfied with the transition. I took it a step further and made the implementation more flexible. This required implementing the Transition protocol to create a custom transition.

struct RevealFrom: Transition {
    var edge: Edge

    func body(content: Content, phase: TransitionPhase) -> some View {
        let (anchor, x, y): (UnitPoint, CGFloat, CGFloat) =
            switch edge {
            case .top:
                (UnitPoint.top, 1, phase.isIdentity ? 1 : 0.0001)
            case .bottom:
                (UnitPoint.bottom, 1, phase.isIdentity ? 1 : 0.0001)
            case .leading:
                (UnitPoint.leading, phase.isIdentity ? 1 : 0.0001, 1)
            case .trailing:
                (UnitPoint.trailing, phase.isIdentity ? 1 : 0.0001, 1)
            }

        return content.scaleEffect(
            x: x,
            y: y,
            anchor: anchor
        )
    }
}

I use scaleEffect directly on the content, so there isn’t a need for view modifiers. The transition only needs to reveal/grow from an edge; the Edge enum was used to constrain the possible anchor points compared to what’s available for the scale transition. Using this transition with a view looks like this

Text("revealing from the leading edge")
    .transition(RevealFrom(edge: .leading))

Here’s how the transition looks from each edge

If you want to use it like other SwiftUI provided transitions you can extend AnyTransition

extension AnyTransition {
    static func revealFrom(edge: Edge) -> AnyTransition {
        AnyTransition(RevealFrom(edge: edge))
    }
}

and then use it

Text("revealing from the leading edge")
    .transition(.revealFrom(edge: .leading))

Here’s the full implementation for the flexible version of the transition

struct RevealFrom: Transition {
    var edge: Edge

    func body(content: Content, phase: TransitionPhase) -> some View {
        let (anchor, x, y): (UnitPoint, CGFloat, CGFloat) =
            switch edge {
            case .top:
                (UnitPoint.top, 1, phase.isIdentity ? 1 : 0.0001)
            case .bottom:
                (UnitPoint.bottom, 1, phase.isIdentity ? 1 : 0.0001)
            case .leading:
                (UnitPoint.leading, phase.isIdentity ? 1 : 0.0001, 1)
            case .trailing:
                (UnitPoint.trailing, phase.isIdentity ? 1 : 0.0001, 1)
            }

        return content.scaleEffect(
            x: x,
            y: y,
            anchor: anchor
        )
    }
}

extension AnyTransition {
    static func revealFrom(edge: Edge) -> AnyTransition {
        AnyTransition(RevealFrom(edge: edge))
    }
}

And here’s a simple example with a preview demonstrating usage

import SwiftUI

struct Example: View {
    @State var text: String
    @State var note: String
    @State var isVisible = false

    var body: some View {
        VStack(alignment: .leading) {
            HStack(alignment: .firstTextBaseline) {
                Button("Toggle") {
                    withAnimation(.spring(duration: 0.1)) {
                        isVisible.toggle()
                    }
                }
            }
            .frame(maxWidth: .infinity, alignment: .topLeading)
            if isVisible {
                VStack {
                    TextField("example", text: $text).textFieldStyle(.plain)
                        .padding(4)
                    TextField("note", text: $note).textFieldStyle(.plain)
                        .padding(4)
                }
                .padding(12)
                .background(Color.gray.opacity(0.2))
                .clipShape(RoundedRectangle(cornerRadius: 8))
                    .transition(.revealFrom(edge: .leading))
            }
            Spacer()
        }
    }
}

#Preview {
    Example(text: "hello", note: "note")
        .padding(8)
}

Thanks for reading and I hope this was helpful.