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() | |
| } | |
| } |