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.