xiaohongshu
xiaohongshu
animations-transitions
SwiftUI animations, @Animatable macro, withAnimation, transitions, PhaseAnimator, KeyframeAnimator, and interactive motion design. Use when user asks about animations, transitions, @Animatable, withAnimation, spring animations, keyframes, or motion design.
Full skill instructions
Comprehensive guide to SwiftUI animations, the new @Animatable macro (iOS 26), transitions, and motion design best practices for modern iOS development.
iOS 26 introduces the @Animatable macro, eliminating the tedious boilerplate previously required for animating custom shapes and views.
// OLD WAY - Lots of boilerplate
struct PieSlice: Shape {
var startAngle: Angle
var endAngle: Angle
// Manual animatableData implementation required
var animatableData: AnimatablePair<Double, Double> {
get {
AnimatablePair(startAngle.radians, endAngle.radians)
}
set {
startAngle = Angle(radians: newValue.first)
endAngle = Angle(radians: newValue.second)
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
path.move(to: center)
path.addArc(center: center, radius: radius,
startAngle: startAngle, endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
// NEW WAY - Just add @Animatable
@Animatable
struct PieSlice: Shape {
var startAngle: Angle
var endAngle: Angle
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
path.move(to: center)
path.addArc(center: center, radius: radius,
startAngle: startAngle, endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
Exclude properties from animation:
@Animatable
struct CustomShape: Shape {
var animatedValue: CGFloat
@AnimatableIgnored var staticConfiguration: Bool // Not animated
func path(in rect: CGRect) -> Path {
// Use both values, but only animatedValue will animate
}
}
The @Animatable macro automatically handles:
CGFloat, Double, FloatAngleCGSize, CGPoint, CGRectUnitPointColor (component interpolation)VectorArithmetic@Animatable
struct MorphingShape: Shape {
var cornerRadius: CGFloat
var insetAmount: CGFloat
var rotation: Angle
@AnimatableIgnored var fillColor: Color
func path(in rect: CGRect) -> Path {
let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
var path = Path(roundedRect: insetRect, cornerRadius: cornerRadius)
let transform = CGAffineTransform(rotationAngle: rotation.radians)
return path.applying(transform)
}
}
// Usage
struct MorphingView: View {
@State private var isExpanded = false
var body: some View {
MorphingShape(
cornerRadius: isExpanded ? 50 : 10,
insetAmount: isExpanded ? 20 : 50,
rotation: isExpanded ? .degrees(45) : .zero,
fillColor: .blue
)
.fill(.blue)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
}
struct ContentView: View {
@State private var isExpanded = false
var body: some View {
VStack {
Rectangle()
.frame(width: isExpanded ? 200 : 100,
height: isExpanded ? 200 : 100)
Button("Toggle") {
withAnimation {
isExpanded.toggle()
}
}
}
}
}
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
isExpanded.toggle()
}
withAnimation(.easeInOut(duration: 0.3)) {
opacity = 1.0
}
withAnimation(.linear(duration: 1.0)) {
progress = 1.0
}
withAnimation(.easeInOut(duration: 0.5)) {
showDetails = true
} completion: {
// Called when animation completes
print("Animation finished")
fetchMoreData()
}
Button("Animate") {
withAnimation(.spring(duration: 0.4)) {
isExpanded = true
}
withAnimation(.easeOut(duration: 0.6).delay(0.2)) {
opacity = 1.0
}
}
// Linear - constant speed
.linear(duration: 0.3)
// Ease variants - acceleration/deceleration
.easeIn(duration: 0.3) // Slow start
.easeOut(duration: 0.3) // Slow end
.easeInOut(duration: 0.3) // Slow both
// Default (ease in/out)
.default
// Modern spring with duration and bounce
.spring(duration: 0.5, bounce: 0.3)
// Bounce values:
// 0.0 = no bounce (critically damped)
// 0.5 = medium bounce
// 1.0 = maximum bounce (never settles)
// Extra bounce parameter for overshoot
.spring(duration: 0.5, bounce: 0.4, blendDuration: 0.2)
// Bouncy - playful, energetic
.bouncy
.bouncy(duration: 0.4, extraBounce: 0.1)
// Snappy - quick, responsive
.snappy
.snappy(duration: 0.3, extraBounce: 0.05)
// Smooth - gentle, elegant
.smooth
.smooth(duration: 0.5, extraBounce: 0.0)
// For gesture-driven animations
.interactiveSpring()
.interactiveSpring(response: 0.3, dampingFraction: 0.7)
// Best for drag gestures
.interactiveSpring(response: 0.15, dampingFraction: 0.86, blendDuration: 0.25)
// Bezier curve timing
.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5)
// Parameters: (x1, y1, x2, y2)
// Start: (0, 0), End: (1, 1)
// Control points define the curve shape
// Delay before starting
.spring().delay(0.2)
// Speed multiplier
.spring().speed(2.0) // 2x faster
// Repeat
.linear(duration: 1.0).repeatCount(3)
.linear(duration: 1.0).repeatForever()
// Autoreverse
.easeInOut(duration: 0.5).repeatForever(autoreverses: true)
struct ContentView: View {
@State private var scale = 1.0
var body: some View {
Circle()
.scaleEffect(scale)
// Explicit: animate only when scale changes
.animation(.spring, value: scale)
.onTapGesture {
scale = scale == 1.0 ? 1.5 : 1.0
}
}
}
// AVOID: Implicit animation (animates everything)
Circle()
.animation(.spring) // Deprecated warning in newer iOS
// PREFER: Explicit animation (precise control)
Circle()
.animation(.spring, value: specificValue)
struct MultiAnimatedView: View {
@State private var scale = 1.0
@State private var opacity = 1.0
@State private var rotation = 0.0
var body: some View {
Rectangle()
.scaleEffect(scale)
.opacity(opacity)
.rotationEffect(.degrees(rotation))
// Different animations for different properties
.animation(.bouncy, value: scale)
.animation(.easeOut(duration: 0.2), value: opacity)
.animation(.spring(duration: 1.0), value: rotation)
}
}
struct ContentView: View {
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.slide)
}
Button("Toggle") {
withAnimation {
showDetail.toggle()
}
}
}
}
}
.transition(.opacity) // Fade in/out
.transition(.scale) // Scale from center
.transition(.scale(scale: 0.5)) // Scale from 50%
.transition(.slide) // Slide from leading edge
.transition(.move(edge: .top)) // Move from specific edge
.transition(.push(from: .bottom)) // Push with replacement
.transition(.offset(x: 100, y: 0)) // Custom offset
// Combine multiple transitions
.transition(.scale.combined(with: .opacity))
// Chain combinations
.transition(
.scale(scale: 0.8)
.combined(with: .opacity)
.combined(with: .offset(y: 20))
)
// Different transitions for insert vs removal
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .slide
))
// Common pattern: slide in from one side, out the other
.transition(.asymmetric(
insertion: .push(from: .trailing),
removal: .push(from: .leading)
))
extension AnyTransition {
static var flipFromBottom: AnyTransition {
.modifier(
active: FlipModifier(angle: -90),
identity: FlipModifier(angle: 0)
)
}
}
struct FlipModifier: ViewModifier {
let angle: Double
func body(content: Content) -> some View {
content
.rotation3DEffect(
.degrees(angle),
axis: (x: 1, y: 0, z: 0)
)
.opacity(angle == 0 ? 1 : 0)
}
}
// Usage
DetailView()
.transition(.flipFromBottom)
enum AnimationPhase: CaseIterable {
case initial
case middle
case final
var scale: CGFloat {
switch self {
case .initial: return 1.0
case .middle: return 1.2
case .final: return 1.0
}
}
var opacity: Double {
switch self {
case .initial: return 1.0
case .middle: return 0.5
case .final: return 1.0
}
}
}
struct PulsingView: View {
var body: some View {
PhaseAnimator(AnimationPhase.allCases) { phase in
Circle()
.fill(.blue)
.scaleEffect(phase.scale)
.opacity(phase.opacity)
}
}
}
struct TriggerableAnimation: View {
@State private var trigger = false
var body: some View {
VStack {
PhaseAnimator(
AnimationPhase.allCases,
trigger: trigger
) { phase in
Star()
.scaleEffect(phase.scale)
.rotationEffect(.degrees(phase.rotation))
}
Button("Animate") {
trigger.toggle()
}
}
}
}
PhaseAnimator(AnimationPhase.allCases) { phase in
ContentView(phase: phase)
} animation: { phase in
switch phase {
case .initial: .spring(duration: 0.3)
case .middle: .easeOut(duration: 0.2)
case .final: .bouncy
}
}
struct AnimationValues {
var scale = 1.0
var rotation = 0.0
var yOffset = 0.0
}
struct BouncingView: View {
@State private var trigger = false
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.keyframeAnimator(
initialValue: AnimationValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(.degrees(value.rotation))
.offset(y: value.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.2, duration: 0.2)
SpringKeyframe(0.9, duration: 0.15)
SpringKeyframe(1.0, duration: 0.15)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(0, duration: 0.1)
SpringKeyframe(10, duration: 0.15)
SpringKeyframe(-10, duration: 0.15)
SpringKeyframe(0, duration: 0.1)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-30, duration: 0.2)
SpringKeyframe(0, duration: 0.3)
}
}
.onTapGesture {
trigger.toggle()
}
}
}
KeyframeTrack(\.value) {
// Linear interpolation
LinearKeyframe(targetValue, duration: 0.3)
// Spring-based interpolation
SpringKeyframe(targetValue, duration: 0.3)
SpringKeyframe(targetValue, duration: 0.3, spring: .bouncy)
// Cubic bezier interpolation
CubicKeyframe(targetValue, duration: 0.3)
// Move without animation
MoveKeyframe(targetValue)
}
struct ComplexAnimationValues {
var xOffset = 0.0
var yOffset = 0.0
var scale = 1.0
var opacity = 1.0
var blur = 0.0
}
struct ComplexAnimation: View {
@State private var animating = false
var body: some View {
Image(systemName: "star.fill")
.font(.system(size: 50))
.keyframeAnimator(
initialValue: ComplexAnimationValues(),
repeating: animating
) { content, value in
content
.offset(x: value.xOffset, y: value.yOffset)
.scaleEffect(value.scale)
.opacity(value.opacity)
.blur(radius: value.blur)
} keyframes: { _ in
KeyframeTrack(\.xOffset) {
LinearKeyframe(0, duration: 0.25)
LinearKeyframe(100, duration: 0.5)
LinearKeyframe(100, duration: 0.25)
LinearKeyframe(0, duration: 0.5)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-50, duration: 0.5)
SpringKeyframe(0, duration: 0.5)
}
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.25)
SpringKeyframe(1.0, duration: 0.25)
SpringKeyframe(1.2, duration: 0.25)
SpringKeyframe(1.0, duration: 0.25)
}
}
.onAppear {
animating = true
}
}
}
struct HeroTransition: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
// Expanded state
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 300, height: 400)
} else {
// Collapsed state
RoundedRectangle(cornerRadius: 10)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 100, height: 100)
}
}
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
}
struct TabBar: View {
@Namespace private var animation
@State private var selectedTab = 0
let tabs = ["Home", "Search", "Profile"]
var body: some View {
HStack {
ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in
Button(title) {
withAnimation(.spring(duration: 0.3)) {
selectedTab = index
}
}
.padding()
.background {
if selectedTab == index {
Capsule()
.fill(.blue.opacity(0.2))
.matchedGeometryEffect(id: "background", in: animation)
}
}
}
}
}
}
.matchedGeometryEffect(
id: "identifier",
in: namespace,
properties: .frame, // What to match: .frame, .position, .size
anchor: .center, // Anchor point for matching
isSource: true // Whether this is the source of truth
)
struct CounterView: View {
@State private var count = 0
var body: some View {
Text("\(count)")
.font(.largeTitle)
.contentTransition(.numericText())
.onTapGesture {
withAnimation {
count += 1
}
}
}
}
// Numeric text morphing
.contentTransition(.numericText())
.contentTransition(.numericText(value: count))
.contentTransition(.numericText(countsDown: true))
// Interpolate between text
.contentTransition(.interpolate)
// Identity (no transition)
.contentTransition(.identity)
// Opacity crossfade
.contentTransition(.opacity)
// Symbol effect
.contentTransition(.symbolEffect(.replace))
struct SymbolEffectsView: View {
@State private var isActive = false
var body: some View {
VStack(spacing: 30) {
// Bounce effect
Image(systemName: "bell.fill")
.symbolEffect(.bounce, value: isActive)
// Pulse effect
Image(systemName: "heart.fill")
.symbolEffect(.pulse)
// Variable color
Image(systemName: "wifi")
.symbolEffect(.variableColor.iterative)
// Scale effect
Image(systemName: "star.fill")
.symbolEffect(.scale.up, isActive: isActive)
// Replace effect
Image(systemName: isActive ? "checkmark.circle" : "circle")
.contentTransition(.symbolEffect(.replace))
Button("Toggle") {
withAnimation {
isActive.toggle()
}
}
}
.font(.largeTitle)
}
}
// Bounce variations
.symbolEffect(.bounce)
.symbolEffect(.bounce.up)
.symbolEffect(.bounce.down)
.symbolEffect(.bounce.byLayer)
.symbolEffect(.bounce.wholeSymbol)
// Variable color
.symbolEffect(.variableColor)
.symbolEffect(.variableColor.iterative)
.symbolEffect(.variableColor.reversing)
.symbolEffect(.variableColor.cumulative)
// Scale
.symbolEffect(.scale.up)
.symbolEffect(.scale.down)
// Pulse
.symbolEffect(.pulse)
.symbolEffect(.pulse.byLayer)
struct DraggableCard: View {
@State private var offset = CGSize.zero
@State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.frame(width: 200, height: 300)
.offset(offset)
.scaleEffect(isDragging ? 1.05 : 1.0)
.animation(.interactiveSpring(response: 0.3), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
isDragging = false
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
offset = .zero
}
}
)
}
}
struct SmoothDrag: View {
@GestureState private var dragOffset = CGSize.zero
@State private var position = CGSize.zero
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.offset(
x: position.width + dragOffset.width,
y: position.height + dragOffset.height
)
.animation(.interactiveSpring(), value: dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
position.width += value.translation.width
position.height += value.translation.height
}
)
}
}
struct VelocityDrag: View {
@State private var offset = CGSize.zero
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.frame(width: 200, height: 300)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { value in
// Use velocity for natural-feeling spring back
let velocity = CGSize(
width: value.predictedEndTranslation.width - value.translation.width,
height: value.predictedEndTranslation.height - value.translation.height
)
withAnimation(.spring(
response: 0.5,
dampingFraction: 0.7,
blendDuration: 0
)) {
offset = .zero
}
}
)
}
}
struct ScrollTransitionView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(0..<20) { index in
RoundedRectangle(cornerRadius: 12)
.fill(.blue.gradient)
.frame(height: 100)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.5)
.scaleEffect(phase.isIdentity ? 1 : 0.9)
.blur(radius: phase.isIdentity ? 0 : 2)
}
}
}
.padding()
}
}
}
struct ParallaxScroll: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(0..<10) { index in
Image("photo\(index)")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 300)
.clipped()
.visualEffect { content, proxy in
content
.offset(y: parallaxOffset(proxy))
}
}
}
}
}
func parallaxOffset(_ proxy: GeometryProxy) -> CGFloat {
let frame = proxy.frame(in: .scrollView)
return -frame.minY * 0.3
}
}
// GOOD: Explicit animation tied to specific value
.animation(.spring, value: isExpanded)
// AVOID: Implicit animation (deprecated, less performant)
.animation(.spring)
// GOOD: Animate transforms (GPU-accelerated)
.scaleEffect(scale)
.rotationEffect(.degrees(rotation))
.offset(x: offsetX, y: offsetY)
.opacity(opacity)
// AVOID: Animating layout-affecting properties when possible
// (These trigger re-layout each frame)
.frame(width: animatedWidth)
.padding(animatedPadding)
// GOOD: Animation on specific view
ChildView()
.animation(.spring, value: childState)
// AVOID: Animation on parent affecting all children
ParentView()
.animation(.spring, value: anyChange) // All children animate
// For complex composited views
ComplexAnimatedShape()
.drawingGroup() // Renders to offscreen buffer
// RECOMMENDED: Under 0.4 seconds for UI feedback
.spring(duration: 0.3, bounce: 0.2)
// Reserve longer animations for:
// - Onboarding flows
// - Celebrations
// - State transitions
// Simulator timing is NOT accurate
// Always test animations on physical device
// Different devices have different performance characteristics
struct LoadingSpinner: View {
@State private var isAnimating = false
var body: some View {
Circle()
.trim(from: 0, to: 0.7)
.stroke(.blue, lineWidth: 4)
.frame(width: 40, height: 40)
.rotationEffect(.degrees(isAnimating ? 360 : 0))
.animation(
.linear(duration: 1.0).repeatForever(autoreverses: false),
value: isAnimating
)
.onAppear {
isAnimating = true
}
}
}
struct PulsingDot: View {
@State private var isPulsing = false
var body: some View {
Circle()
.fill(.green)
.frame(width: 12, height: 12)
.scaleEffect(isPulsing ? 1.2 : 1.0)
.opacity(isPulsing ? 0.6 : 1.0)
.animation(
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
value: isPulsing
)
.onAppear {
isPulsing = true
}
}
}
struct ShakeEffect: GeometryEffect {
var amount: CGFloat = 10
var shakesPerUnit = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX:
amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
y: 0))
}
}
// Usage
TextField("Email", text: $email)
.modifier(ShakeEffect(animatableData: shakeAmount))
.onChange(of: hasError) {
withAnimation(.default) {
shakeAmount = hasError ? 1 : 0
}
}
xiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience