Blob transitions between two symbols in SwiftUI
https://twitter.com/auramagi/status/1572213629709357057?s=20&t=KVRPO5s5R4ifhB2OfOQllg
Blob transitions between two symbols in SwiftUI
https://twitter.com/auramagi/status/1572213629709357057?s=20&t=KVRPO5s5R4ifhB2OfOQllg
import SwiftUI | |
struct BlobbySymbol<Primary: View, Alternative: View>: Animatable, View { | |
private var progress: Double = 0 | |
var animatableData: Double { | |
get { progress } | |
set { progress = newValue } | |
} | |
let maxBlur: Double | |
let alphaThreshold: Double | |
let primary: Primary | |
let alternative: Alternative | |
init( | |
isPrimary: Bool, | |
maxBlur: Double = 25, | |
alphaThreshold: Double = 1 / 4, | |
@ViewBuilder primary: () -> Primary, | |
@ViewBuilder alternative: () -> Alternative | |
) { | |
self.progress = isPrimary ? 0 : 1 | |
self.maxBlur = maxBlur | |
self.alphaThreshold = alphaThreshold | |
self.primary = primary() | |
self.alternative = alternative() | |
} | |
var body: some View { | |
Group { | |
// Place a hidden view for sizing, or else Canvas will take up all available space | |
if progress >= 0.5 { | |
alternative | |
} else { | |
primary | |
} | |
} | |
.hidden() | |
.overlay( | |
Canvas(opaque: false, colorMode: .nonLinear, rendersAsynchronously: false) { ctx, size in | |
let bounds = CGRect(origin: .zero, size: size) | |
// Drawing the symbol: apply blur and alpha threshold filters | |
// Alpha filter will make the view black, so we use it as a mask and fill bounds with foreground style | |
ctx.clipToLayer { ctx in | |
ctx.addFilter(.alphaThreshold(min: alphaThreshold)) | |
// Progress → blur: 0% → 0, 50% → maxBlur, 100% → 0 | |
ctx.addFilter(.blur(radius: (0.5 - abs(progress - 0.5)) * maxBlur)) | |
ctx.drawLayer { ctx in | |
// Swap symbols at 50% | |
ctx.draw(ctx.resolveSymbol(id: progress >= 0.5)!, in: bounds) | |
} | |
} | |
ctx.fill(.init(.init(origin: .zero, size: size)), with: .foreground) | |
} symbols: { | |
primary.tag(false) | |
alternative.tag(true) | |
} | |
) | |
} | |
} |
import SwiftUI | |
struct ContentView: View { | |
@State var flag = true | |
@State var selection: Int = 0 | |
var body: some View { | |
VStack { | |
Group { | |
switch selection { | |
case 0: | |
Button { | |
flag.toggle() | |
} label: { | |
BlobbySymbol(isPrimary: flag) { | |
Image(systemName: "play") | |
} alternative: { | |
Image(systemName: "pause") | |
} | |
.foregroundStyle(.mint.gradient) | |
.font(.system(size: 75, weight: .medium, design: .rounded)) | |
.symbolVariant(.fill) | |
.animation(.easeOut, value: flag) | |
} | |
.buttonStyle(.plain) | |
case 1: | |
Button { | |
flag.toggle() | |
} label: { | |
BlobbySymbol(isPrimary: flag) { | |
Image(systemName: "xmark") | |
} alternative: { | |
Image(systemName: "checkmark") | |
} | |
.foregroundStyle(.white) | |
.colorMultiply(flag ? Color.red : Color.green) | |
.font(.system(size: 75, weight: .medium, design: .rounded)) | |
.animation(.easeOut, value: flag) | |
} | |
.buttonStyle(.plain) | |
case 2: | |
Button { | |
flag.toggle() | |
} label: { | |
BlobbySymbol(isPrimary: flag) { | |
Image(systemName: "questionmark") | |
} alternative: { | |
Image(systemName: "exclamationmark") | |
} | |
.foregroundStyle(.linearGradient(colors: [.purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing)) | |
.font(.system(size: 75, weight: .medium, design: .rounded)) | |
.animation(.easeOut, value: flag) | |
} | |
.buttonStyle(.plain) | |
default: | |
EmptyView() | |
} | |
} | |
.frame(maxHeight: .infinity) | |
.transition(.opacity.combined(with: .scale)) | |
Picker(selection: $selection) { | |
Image(systemName: "play") | |
.symbolVariant(.fill) | |
.tag(0) | |
Image(systemName: "xmark") | |
.tag(1) | |
Image(systemName: "questionmark") | |
.tag(2) | |
} label: { | |
Text("Selection") | |
} | |
.pickerStyle(.segmented) | |
.padding() | |
} | |
.animation(.easeOut, value: selection) | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} |