Any Distance Goes Open Source

4 hours ago 2
Blog

Any Distance

A note from Dan: Today I'm open sourcing Any Distance, a fitness tracker app I worked on alongside several others for almost 5 years. You can read my announcement blog post here where I share some of motivations for open sourcing the app.

This collaboration with SIP includes code snippets, screen recordings, and commentary on some of my favorite parts of the app. You can view the full source code for Any Distance here.

The code snippets here are meant to be illustrative. They're not complete implementations. Please be sure to click through to GitHub to get the full picture.

3D Routes

import Foundation import SceneKit import SCNLine import CoreLocation struct RouteScene { let scene: SCNScene let camera: SCNCamera let lineNode: SCNLineNode let centerNode: SCNNode let dotNode: SCNNode let dotAnimationNode: SCNNode let planeNodes: [SCNNode] let animationDuration: TimeInterval let elevationMinNode: SCNNode? let elevationMinLineNode: SCNNode? let elevationMaxNode: SCNNode? let elevationMaxLineNode: SCNNode? private let forExport: Bool private let elevationMinTextAction: SCNAction? private let elevationMaxTextAction: SCNAction? fileprivate static let dotRadius: CGFloat = 3.0 fileprivate static let initialFocalLength: CGFloat = 42.0 fileprivate static let initialZoom: CGFloat = 1.0 fileprivate var minElevation: CLLocationDistance = 0.0 fileprivate var maxElevation: CLLocationDistance = 0.0 fileprivate var minElevationPoint = SCNVector3(0, 1000, 0) fileprivate var maxElevationPoint = SCNVector3(0, -1000, 0) var zoom: CGFloat = 1.0 { didSet { camera.focalLength = Self.initialFocalLength * zoom } } var palette: Palette { didSet { lineNode.geometry?.firstMaterial?.diffuse.contents = palette.foregroundColor let darkeningPercentage: CGFloat = forExport ? 0.0 : 35.0 let alpha = (palette.foregroundColor.isReallyDark ? 0.8 : 0.5) + (forExport ? 0.2 : 0.0) let color = palette.foregroundColor.darker(by: darkeningPercentage)?.withAlphaComponent(alpha) for plane in planeNodes { plane.geometry?.firstMaterial?.diffuse.contents = color } dotNode.geometry?.firstMaterial?.diffuse.contents = palette.accentColor dotAnimationNode.geometry?.firstMaterial?.diffuse.contents = palette.accentColor elevationMinNode?.geometry?.firstMaterial?.diffuse.contents = palette.foregroundColor.lighter(by: 15.0) elevationMinLineNode?.geometry?.firstMaterial?.diffuse.contents = palette.foregroundColor.lighter(by: 15.0) elevationMaxNode?.geometry?.firstMaterial?.diffuse.contents = palette.foregroundColor.lighter(by: 15.0) elevationMaxLineNode?.geometry?.firstMaterial?.diffuse.contents = palette.foregroundColor.lighter(by: 15.0) } } } // MARK: Init extension RouteScene { func restartTextAnimation() { if let minAction = elevationMinTextAction { elevationMinNode?.runAction(minAction) } if let maxAction = elevationMaxTextAction { elevationMaxNode?.runAction(maxAction) } } private static func textAction(for node: SCNNode, lineNode: SCNNode, startPosition: SCNVector3, initialDelay: CGFloat, additionalDelay: CGFloat, icon: String, elevationLimit: CLLocationDistance) -> SCNAction { let textAnimationDuration: CGFloat = 2.2 let delay: CGFloat = textAnimationDuration * 0.15 var actions: [SCNAction] = [] let textAction = SCNAction.customAction(duration: textAnimationDuration) { node, time in let p = time / textAnimationDuration let elevation = Self.easeOutQuint(x: p) * elevationLimit if let geo = node.geometry as? SCNText { geo.string = "\(icon)\(Int(elevation))" + (ADUser.current.distanceUnit == .miles ? "ft" : "m") } } let opacityDelay: CGFloat = textAnimationDuration * 0.2 let opacityDuration: CGFloat = textAnimationDuration * 0.55 let transformDuration: CGFloat = textAnimationDuration * 0.45 let movementAmount: CGFloat = 18.0 let lineDuration: CGFloat = textAnimationDuration * 0.2 let moveBy = SCNAction.moveBy(x: 0.0, y: movementAmount, z: 0.0, duration: transformDuration) moveBy.timingFunction = { t in return Float(Self.easeOutQuad(x: CGFloat(t) / transformDuration)) } actions.append(SCNAction.sequence([ SCNAction.run({ _ in lineNode.runAction(SCNAction.fadeOut(duration: 0.0)) }), SCNAction.fadeOut(duration: 0.0), SCNAction.move(to: startPosition, duration: 0.0), SCNAction.moveBy(x: 0.0, y: -movementAmount, z: 0.0, duration: 0.0), SCNAction.wait(duration: delay + additionalDelay * opacityDelay), SCNAction.group([ SCNAction.fadeIn(duration: opacityDuration), textAction, SCNAction.sequence([ moveBy, SCNAction.run({ _ in lineNode.runAction(SCNAction.fadeIn(duration: lineDuration)) }) ]) ]) ])) return SCNAction.group(actions) } static func routeScene(from coordinates: [CLLocation], forExport: Bool, palette: Palette = .dark) -> RouteScene? { guard !coordinates.isEmpty else { return nil } let latitudeMin: CLLocationDegrees = coordinates.min(by: { $0.coordinate.latitude < $1.coordinate.latitude })?.coordinate.latitude ?? 1 let latitudeMax: CLLocationDegrees = coordinates.max(by: { $0.coordinate.latitude < $1.coordinate.latitude })?.coordinate.latitude ?? 1 let longitudeMin: CLLocationDegrees = coordinates.min(by: { $0.coordinate.longitude < $1.coordinate.longitude })?.coordinate.longitude ?? 1 let longitudeMax: CLLocationDegrees = coordinates.max(by: { $0.coordinate.longitude < $1.coordinate.longitude })?.coordinate.longitude ?? 1 let altitudeMin = coordinates.min(by: { $0.altitude < $1.altitude })?.altitude ?? 0.0 let altitudeMax = coordinates.max(by: { $0.altitude < $1.altitude })?.altitude ?? 0.0 let altitudeRange = altitudeMax - altitudeMin let latitudeRange = latitudeMax - latitudeMin let longitudeRange = longitudeMax - longitudeMin let aspectRatio = CGSize(width: CGFloat(longitudeRange), height: CGFloat(latitudeRange)) let bounds = CGSize.aspectFit(aspectRatio: aspectRatio, boundingSize: CGSize(width: 200.0, height: 200.0)) let scene = SCNScene() scene.background.contents = UIColor.clear let routeCenterNode = SCNNode(geometry: SCNSphere(radius: 0.0)) routeCenterNode.position = SCNVector3(0.0, 0.0, 0.0) scene.rootNode.addChildNode(routeCenterNode) var prevPoint: SCNVector3? let smoothing: Float = 0.2 let elevationSmoothing: Float = 0.3 let s = max(1, coordinates.count / 350) let dotNode = SCNNode(geometry: SCNSphere(radius: dotRadius)) let dotAnimationNode = SCNNode(geometry: SCNSphere(radius: dotRadius)) dotAnimationNode.castsShadow = false var points: [SCNVector3] = [] var keyTimes: [NSNumber] = [] var curTime = 0.0 let degreesPerMeter = 0.0001 let latitudeMultiple = Double(bounds.height) / latitudeRange let renderedAltitudeRange = (degreesPerMeter * latitudeMultiple * altitudeRange).clamped(to: 0...80) let altitudeMultiplier = altitudeRange == 0 ? 0.1 : (renderedAltitudeRange / altitudeRange) var planeNodes = [SCNNode]() var minElevation: CLLocationDistance = 0.0 var maxElevation: CLLocationDistance = 0.0 var minElevationPoint = SCNVector3(0, 1000, 0) var maxElevationPoint = SCNVector3(0, -1000, 0) for i in stride(from: 0, to: coordinates.count - 1, by: s) { let c = coordinates[i] let normalizedLatitude = (1 - ((c.coordinate.latitude - latitudeMin) / latitudeRange)) let latitude = Double(bounds.height) * normalizedLatitude - Double(bounds.height / 2) let longitude = Double(bounds.width) * ((c.coordinate.longitude - longitudeMin) / longitudeRange) - Double(bounds.width / 2) let adjustedAltitude = (c.altitude - altitudeMin) * altitudeMultiplier var point = SCNVector3(longitude, adjustedAltitude, latitude) if i == 0 { dotNode.position = point } if let prevPoint = prevPoint { // smoothing point.x = (point.x * (1 - smoothing)) + (prevPoint.x * smoothing) + Float.random(in: -0.001...0.001) point.y = (point.y * (1 - elevationSmoothing)) + (prevPoint.y * elevationSmoothing) + Float.random(in: -0.001...0.001) point.z = (point.z * (1 - smoothing)) + (prevPoint.z * smoothing) + Float.random(in: -0.001...0.001) // draw elevation plane let point3 = SCNVector3(point.x, -18.0, point.z) let point4 = SCNVector3(prevPoint.x, -18.0, prevPoint.z) let plane = SCNNode.planeNode(corners: [point, prevPoint, point3, point4]) let boxMaterial = SCNMaterial() boxMaterial.transparent.contents = UIImage(named: "route_plane_fade")! boxMaterial.lightingModel = .constant boxMaterial.diffuse.contents = UIColor.white boxMaterial.blendMode = .replace boxMaterial.isLitPerPixel = false boxMaterial.isDoubleSided = true plane.geometry?.materials = [boxMaterial] routeCenterNode.addChildNode(plane) planeNodes.append(plane) let duration = TimeInterval(point.distance(to: prevPoint) * 0.02) curTime += duration points.append(point) keyTimes.append(NSNumber(value: curTime)) } else { points.append(point) keyTimes.append(NSNumber(value: 0)) } if point.y < minElevationPoint.y { minElevationPoint = point minElevation = c.altitude } if point.y > maxElevationPoint.y { maxElevationPoint = point maxElevation = c.altitude } prevPoint = point } if ADUser.current.distanceUnit == .miles { minElevation = UnitConverter.metersToFeet(minElevation) maxElevation = UnitConverter.metersToFeet(maxElevation) } let lineNode = SCNLineNode(with: points, radius: 1, edges: 5, maxTurning: 4) let lineMaterial = SCNMaterial() lineMaterial.lightingModel = .constant lineMaterial.isLitPerPixel = false lineNode.geometry?.materials = [lineMaterial] routeCenterNode.addChildNode(lineNode) let animationDuration = curTime let centerNode = SCNNode(geometry: SCNSphere(radius: 0)) centerNode.position = SCNVector3(0, 0, 0) scene.rootNode.addChildNode(centerNode) let camera = SCNCamera() let cameraNode = SCNNode() cameraNode.camera = camera camera.automaticallyAdjustsZRange = true camera.focalLength = initialFocalLength * initialZoom cameraNode.position = SCNVector3(0, 250, 450) scene.rootNode.addChildNode(cameraNode) let lookAtConstraint = SCNLookAtConstraint(target: centerNode) cameraNode.constraints = [lookAtConstraint] let material = SCNMaterial() material.lightingModel = .constant let dotColor = UIColor(realRed: 255, green: 198, blue: 99) material.diffuse.contents = dotColor material.ambient.contents = dotColor dotNode.geometry?.materials = [material] routeCenterNode.addChildNode(dotNode) let moveAlongPathAnimation = CAKeyframeAnimation(keyPath: "position") moveAlongPathAnimation.values = points moveAlongPathAnimation.keyTimes = keyTimes moveAlongPathAnimation.duration = curTime moveAlongPathAnimation.usesSceneTimeBase = !forExport moveAlongPathAnimation.repeatCount = .greatestFiniteMagnitude dotNode.addAnimation(moveAlongPathAnimation, forKey: "position") let dotAnimationMaterial = SCNMaterial() dotAnimationMaterial.lightingModel = .constant let dotAnimationColor = UIColor(realRed: 255, green: 247, blue: 189) dotAnimationMaterial.diffuse.contents = dotAnimationColor dotAnimationMaterial.ambient.contents = dotAnimationColor dotAnimationNode.geometry?.materials = [dotAnimationMaterial] routeCenterNode.addChildNode(dotAnimationNode) dotAnimationNode.addAnimation(moveAlongPathAnimation, forKey: "position") let scaleAnimation = CABasicAnimation(keyPath: "scale") scaleAnimation.fromValue = SCNVector3(1, 1, 1) scaleAnimation.toValue = SCNVector3(3, 3, 3) scaleAnimation.duration = 0.8 scaleAnimation.repeatCount = .greatestFiniteMagnitude scaleAnimation.usesSceneTimeBase = !forExport dotAnimationNode.addAnimation(scaleAnimation, forKey: "scale") let opacityAnimation = CABasicAnimation(keyPath: "opacity") opacityAnimation.fromValue = 0.5 opacityAnimation.toValue = 0.001 opacityAnimation.duration = 0.8 opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) opacityAnimation.repeatCount = .greatestFiniteMagnitude opacityAnimation.usesSceneTimeBase = !forExport dotAnimationNode.addAnimation(opacityAnimation, forKey: "opacity") let spin = CABasicAnimation(keyPath: "rotation") spin.fromValue = NSValue(scnVector4: SCNVector4(x: 0.0, y: 1.0, z: 0.0, w: 0.0)) spin.toValue = NSValue(scnVector4: SCNVector4(x: 0.0, y: 1.0, z: 0.0, w: 2.0 * .pi)) spin.duration = 14.0 spin.repeatCount = .infinity spin.usesSceneTimeBase = !forExport routeCenterNode.addAnimation(spin, forKey: "rotation") let textMaterial = SCNMaterial() textMaterial.diffuse.contents = UIColor.white textMaterial.lightingModel = .constant textMaterial.isDoubleSided = true var elevationMinNode: SCNNode? var elevationMinTextAction: SCNAction? var elevationMinLineNode: SCNNode? var elevationMaxNode: SCNNode? var elevationMaxTextAction: SCNAction? var elevationMaxLineNode: SCNNode? if altitudeMin != altitudeMax { var i: CGFloat = 0.0 for (elevationPoint, elevation) in zip([minElevationPoint, maxElevationPoint], [minElevation, maxElevation]) { let arrow = elevation == maxElevation ? "▲" : "▼" let text = arrow + String(format: "%i", Int(elevation)) + (ADUser.current.distanceUnit == .miles ? "ft" : "m") let elevationText = SCNText(string: text, extrusionDepth: 0) elevationText.materials = [textMaterial] elevationText.font = UIFont.presicav(size: 36, weight: .heavy) elevationText.flatness = 0.2 let textNode = SCNNode(geometry: elevationText) textNode.name = "elevation-\(elevation == maxElevation ? "max" : "min")" textNode.pivot = SCNMatrix4MakeTranslation(17, 0, 0) textNode.position = SCNVector3(elevationPoint.x, elevationPoint.y + 18, elevationPoint.z) textNode.scale = SCNVector3(0.22, 0.22, 0.22) textNode.constraints = [SCNBillboardConstraint()] textNode.opacity = 0.0 routeCenterNode.addChildNode(textNode) let textLineNode = SCNNode.lineNode(from: SCNVector3(elevationPoint.x, elevationPoint.y, elevationPoint.z), to: SCNVector3(elevationPoint.x, textNode.position.y - 1, elevationPoint.z)) textLineNode.name = textNode.name! + "-line" textLineNode.geometry?.materials = [textMaterial] textLineNode.opacity = 0.0 routeCenterNode.addChildNode(textLineNode) if elevation == minElevation { elevationMinNode = textNode elevationMinLineNode = textLineNode elevationMinTextAction = textAction(for: textNode, lineNode: textLineNode, startPosition: textNode.position, initialDelay: 0.3, additionalDelay: i, icon: arrow, elevationLimit: minElevation) } else { elevationMaxNode = textNode elevationMaxLineNode = textLineNode elevationMaxTextAction = textAction(for: textNode, lineNode: textLineNode, startPosition: textNode.position, initialDelay: 0.7, additionalDelay: i, icon: arrow, elevationLimit: maxElevation) } i += 1 } } var routeScene = RouteScene(scene: scene, camera: camera, lineNode: lineNode, centerNode: routeCenterNode, dotNode: dotNode, dotAnimationNode: dotAnimationNode, planeNodes: planeNodes, animationDuration: animationDuration, elevationMinNode: elevationMinNode, elevationMinLineNode: elevationMinLineNode, elevationMaxNode: elevationMaxNode, elevationMaxLineNode: elevationMaxLineNode, forExport: forExport, elevationMinTextAction: elevationMinTextAction, elevationMaxTextAction: elevationMaxTextAction, zoom: initialZoom, palette: palette) routeScene.palette = palette return routeScene } static fileprivate func easeOutQuint(x: CGFloat) -> CGFloat { return 1.0 - pow(1.0 - x, 5.0) } static func easeOutQuad(x: CGFloat) -> CGFloat { return 1.0 - (1.0 - x) * (1.0 - x) } static func easeInOutQuart(x: CGFloat) -> CGFloat { return x < 0.5 ? 8.0 * pow(x, 4.0) : 1.0 - pow(-2.0 * x + 2.0, 4.0) / 2.0 } }

A fan favorite in Any Distance, these 3D routes are rendered with SceneKit. The geometry is constructed at runtime using a combination of "line nodes" (which are really just low poly cylinders) and planes.

The vertical elevation plane uses a constant lighting model (which basically just skips all lighting and fills everything with one color) and a replace blend mode, so it just replaces whatever is behind it with its own color. This results in a much cleaner look than normal transparency, since you can't see anything "behind" the plane.

I used an SCNAction sequence to animate the elevation labels, and a custom SCNAction to count up the text as the labels rise.

3-2-1 Go Animation

import SwiftUI extension Comparable { func clamped(to limits: ClosedRange<Self>) -> Self { return min(max(self, limits.lowerBound), limits.upperBound) } } struct CountdownView: View { private let impactGenerator = UIImpactFeedbackGenerator(style: .heavy) private let startGenerator = UINotificationFeedbackGenerator() @State private var animationStep: CGFloat = 4 @State private var animationTimer: Timer? @State private var isFinished: Bool = false @Binding var skip: Bool var finishedAction: () -> Void func hStackXOffset() -> CGFloat { let clampedStep = animationStep.clamped(to: 0...3) if clampedStep > 0 { return 60 * (clampedStep - 1) - 10 } else { return -90 } } func startTimer() { animationTimer = Timer.scheduledTimer(withTimeInterval: 0.9, repeats: true, block: { _ in if animationStep == 0 { withAnimation(.easeIn(duration: 0.15)) { isFinished = true } finishedAction() animationTimer?.invalidate() } withAnimation(.easeInOut(duration: animationStep == 4 ? 0.3 : 0.4)) { animationStep -= 1 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if animationStep < 4 && animationStep > 0 { impactGenerator.impactOccurred() } else if animationStep == 0 { startGenerator.notificationOccurred(.success) } } }) } var body: some View { VStack { ZStack { DarkBlurView() HStack(alignment: .center, spacing: 0) { Text("3") .font(.system(size: 89, weight: .semibold, design: .default)) .frame(width: 60) .opacity(animationStep >= 3 ? 1 : 0.6) .scaleEffect(animationStep >= 3 ? 1 : 0.6) Text("2") .font(.system(size: 89, weight: .semibold, design: .default)) .frame(width: 60) .opacity(animationStep == 2 ? 1 : 0.6) .scaleEffect(animationStep == 2 ? 1 : 0.6) Text("1") .font(.system(size: 89, weight: .semibold, design: .default)) .frame(width: 60) .opacity(animationStep == 1 ? 1 : 0.6) .scaleEffect(animationStep == 1 ? 1 : 0.6) Text("GO") .font(.system(size: 65, weight: .bold, design: .default)) .frame(width: 100) .opacity(animationStep == 0 ? 1 : 0.6) .scaleEffect(animationStep == 0 ? 1 : 0.6) } .foregroundStyle(Color.white) .offset(x: hStackXOffset()) } .mask { RoundedRectangle(cornerRadius: 65) .frame(width: 130, height: 200) } .opacity(isFinished ? 0 : 1) .scaleEffect(isFinished ? 1.2 : 1) .blur(radius: isFinished ? 6.0 : 0.0) .opacity(animationStep < 4 ? 1 : 0) .scaleEffect(animationStep < 4 ? 1 : 0.8) } .onChange(of: skip) { newValue in if newValue == true { animationTimer?.invalidate() withAnimation(.easeIn(duration: 0.15)) { isFinished = true } finishedAction() } } .onAppear { guard animationStep == 4 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { self.startTimer() } } } } #Preview { ZStack { CountdownView(skip: .constant(false)) {} } .background(Color(white: 0.2)) }

This is our classic countdown timer written in SwiftUI. It's a good example of how stacking lots of different SwiftUI modifiers (scaleEffect, opacity, blur) can let you create more complex animations.

Goal Picker

import SwiftUI fileprivate struct CancelHeader: View { @Environment(\.presentationMode) var presentationMode @Binding var showingSettings: Bool @Binding var isPresented: Bool var body: some View { HStack { Button { showingSettings = true } label: { Text("Settings") .foregroundColor(.white) .fontWeight(.medium) .frame(width: 95, height: 50) } Spacer() Button { UIApplication.shared.topViewController?.dismiss(animated: true) isPresented = false } label: { Text("Cancel") .foregroundColor(.white) .fontWeight(.medium) .frame(width: 90, height: 50) } } } } fileprivate struct TitleView: View { var activityType: ActivityType var goalType: RecordingGoalType var body: some View { HStack(alignment: .center, spacing: 0) { VStack(alignment: .leading, spacing: 3) { Text(activityType.displayName) .font(.system(size: 28, weight: .semibold, design: .default)) .foregroundColor(.white) .fixedSize(horizontal: false, vertical: true) Text(goalType.promptText) .font(.system(size: 18, weight: .regular, design: .default)) .foregroundColor(.white.opacity(0.6)) .id(goalType.promptText) .modifier(BlurOpacityTransition(speed: 1.75)) } Spacer() Image(activityType.glyphName) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 53, height: 53) } } } fileprivate struct GoalTypeSelector: View { var types: [RecordingGoalType] = [] @Binding var selectedIdx: Int private var scrollViewAnchor: UnitPoint { switch selectedIdx { case RecordingGoalType.allCases.count - 1: return .trailing default: return .center } } var body: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(Array(types.enumerated()), id: \.element.rawValue) { (idx, goalType) in GoalTypeButton(idx: idx, goalType: goalType, selectedIdx: $selectedIdx) } } .padding([.leading, .trailing], 15) } .introspectScrollView { scrollView in scrollView.setValue(0.25, forKeyPath: "contentOffsetAnimationDuration") } .onChange(of: selectedIdx) { newValue in withAnimation { proxy.scrollTo(RecordingGoalType.allCases[selectedIdx].rawValue, anchor: scrollViewAnchor) } } .onAppear { proxy.scrollTo(RecordingGoalType.allCases[selectedIdx].rawValue, anchor: scrollViewAnchor) } } } struct GoalTypeButton: View { var idx: Int var goalType: RecordingGoalType @Binding var selectedIdx: Int var isSelected: Bool { return selectedIdx == idx } func animation(for idx: Int) -> Animation { return idx == 0 ? .timingCurve(0.33, 1, 0.68, 1, duration: 0.35) : .timingCurve(0.25, 1, 0.5, 1, duration: 0.4) } var body: some View { Button { let request = FrameRateRequest(duration: 0.5) request.perform() withAnimation(animation(for: idx)) { selectedIdx = idx } } label: { ZStack { RoundedRectangle(cornerRadius: 40, style: .circular) .fill(isSelected ? Color(goalType.color) : Color(white: 0.2)) .frame(height: 50) HStack(alignment: .center, spacing: 6) { let unit = ADUser.current.distanceUnit if let glyph = goalType.glyph(forDistanceUnit: unit) { Image(uiImage: glyph) } Text(goalType.displayName) .font(.system(size: 18, weight: .semibold, design: .default)) .foregroundColor((isSelected && goalType == .open) ? .black : .white) } .padding([.leading, .trailing], 20) .frame(maxWidth: .infinity) .frame(maxHeight: 50) } .contentShape(Rectangle()) .background(Color.black.opacity(0.01)) .animation(.none, value: isSelected) } } } } fileprivate struct SetTargetView: View { @ObservedObject var goal: RecordingGoal @ObservedObject var howWeCalculateModel: HowWeCalculatePopupModel @State private var originalGoalTarget: Float = -1 @State private var dragStartPoint: CGPoint? private let generator = UISelectionFeedbackGenerator() private var bgRectangle: some View { Rectangle() .padding(.top, 35) .opacity(0.001) .onTapGesture(count: 2) { goal.setTarget(goal.type.defaultTarget) generator.selectionChanged() } .gesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { data in if originalGoalTarget == -1 { originalGoalTarget = goal.target } let prevTarget = goal.target var xOffset = -1 * (data.location.x - (dragStartPoint?.x ?? data.startLocation.x)) let minDistance: CGFloat = 10.0 if abs(xOffset) < minDistance && dragStartPoint == nil { return } if dragStartPoint == nil { dragStartPoint = data.location xOffset = 0.0 } let delta = goal.type.slideIncrement * Float((xOffset / 8).rounded()) let newTarget = ((originalGoalTarget + delta) / goal.type.slideIncrement).rounded() * goal.type.slideIncrement goal.setTarget(newTarget) if goal.target != prevTarget { generator.selectionChanged() } } .onEnded { _ in originalGoalTarget = -1 dragStartPoint = nil } ) } var dotBg: some View { ZStack { Color.black Image("dot_bg") .resizable(resizingMode: .tile) .renderingMode(.template) .foregroundColor(Color(goal.type.lighterColor)) .opacity(0.15) RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke(Color.black, lineWidth: 18) .blur(radius: 16) VStack { Rectangle() .fill( LinearGradient(colors: [.clear, Color(goal.type.color), .clear], startPoint: .leading, endPoint: .trailing) ) .frame(height: 90) .offset(y: -30) Spacer() } .scaleEffect(x: 1.8, y: 3) .blur(radius: 25) .opacity(0.45) VStack { Rectangle() .fill( LinearGradient(colors: [.clear, Color(goal.type.color), Color(goal.type.color), .clear], startPoint: .leading, endPoint: .trailing) ) .frame(height: 1.8) Spacer() } } } var body: some View { VStack(alignment: .center, spacing: 4) { Text(goal.formattedUnitString) .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(Color(goal.type.lighterColor)) .shadow(color: Color(goal.type.color), radius: 6, x: 0, y: 0) .shadow(color: Color.black, radius: 5, x: 0, y: 0) .allowsHitTesting(false) HStack { Button { goal.setTarget(goal.target - goal.type.buttonIncrement) generator.selectionChanged() } label: { Image("glyph_digital_minus") .foregroundColor(Color(white: 0.9)) .frame(width: 70, height: 70) } .padding(.leading, 5) .offset(y: -3) Spacer() Text(goal.formattedTarget) .font(.custom("Digital-7", size: 73)) .foregroundColor(Color(goal.type.lighterColor)) .shadow(color: Color(goal.type.color), radius: 6, x: 0, y: 0) .allowsHitTesting(false) Spacer() Button { goal.setTarget(goal.target + goal.type.buttonIncrement) generator.selectionChanged() } label: { Image("glyph_digital_plus") .foregroundColor(Color(white: 0.9)) .frame(width: 70, height: 70) } .padding(.trailing, 5) .offset(y: -3) } SlideToAdjust(color: goal.type.lighterColor) .shadow(color: Color(goal.type.color), radius: 6, x: 0, y: 0) .allowsHitTesting(false) } .padding([.top, .bottom], 16) .overlay { HStack { VStack { Button { howWeCalculateModel.showStatCalculation(for: goal.type.statisticType) } label: { Image(systemName: .infoCircleFill) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 13) .foregroundColor(Color(goal.type.lighterColor)) .padding(16) } .shadow(color: Color(goal.type.color), radius: 7, x: 0, y: 0) .shadow(color: Color.black, radius: 5, x: 0, y: 0) Spacer() } Spacer() } } .maxWidth(.infinity) .background(bgRectangle) .background(dotBg) .mask(RoundedRectangle(cornerRadius: 18, style: .continuous)) .background { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Color.white.opacity(0.2)) .offset(y: 1) } } struct SlideToAdjust: View { var color: UIColor var body: some View { HStack(spacing: 18) { Arrows(color: color) Text("SLIDE TO ADJUST") .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(Color(color)) Arrows(color: color) .scaleEffect(x: -1, y: 1, anchor: .center) } } struct Arrows: View { let color: UIColor let animDuration: Double = 0.8 @State private var animate: Bool = false var body: some View { HStack(spacing: 4) { ForEach(1...5, id: \.self) { idx in Image(systemName: .chevronLeft) .font(Font.system(size: 10, weight: .heavy)) .foregroundColor(Color(color)) .opacity(animate ? 0.2 : 1) .animation( .easeIn(duration: animDuration) .repeatForever(autoreverses: true) .delay(-1 * Double(idx) * animDuration / 5), value: animate) } } .onAppear { animate = true } } } } } fileprivate struct TargetOpacityAnimation: AnimatableModifier { var progress: CGFloat = 0 var animatableData: CGFloat { get { return progress } set { progress = newValue } } func body(content: Content) -> some View { let scaledProgress = ((progress - 0.3) * 1.5).clamped(to: 0...1) let easedProgress = easeInCubic(scaledProgress) content .opacity(easedProgress) .maxHeight(progress * 140) } func easeInCubic(_ x: CGFloat) -> CGFloat { return x * x * x; } } struct RecordingGoalSelectionView: View { @Environment(\.presentationMode) var presentationMode @StateObject var howWeCalculatePopupModel = HowWeCalculatePopupModel() var activityType: ActivityType @Binding var goal: RecordingGoal @State var hasSetup: Bool = false @State var isPresented: Bool = true @State var hasRecordingViewAppeared: Bool = false @State var showingWeightEntryView: Bool = false @State var showingRecordingSettings: Bool = false @State var selectedGoalTypeIdx: Int = 1 @State var prevSelectedGoalTypeIdx: Int = 1 @State var goals: [RecordingGoal] = [] private let generator = UIImpactFeedbackGenerator(style: .heavy) private let screenName = "Tracking Goal Select" func goal(for idx: Int) -> RecordingGoal { return goals[idx] } func setTargetGoal(for idx: Int) -> RecordingGoal { if selectedGoalTypeIdx == 0 { return goal(for: prevSelectedGoalTypeIdx) } else { return goal(for: idx) } } var body: some View { ZStack { BlurView(style: .systemUltraThinMaterialDark, intensity: 0.55, animatesIn: true, animateOut: !isPresented) .padding(.top, -1500) .opacity(hasRecordingViewAppeared ? 0 : 1) .ignoresSafeArea() .onTapGesture { isPresented = false presentationMode.dismiss() } VStack { HowWeCalculatePopup(model: howWeCalculatePopupModel, drawerClosedHeight: 0) VStack(spacing: 16) { CancelHeader(showingSettings: $showingRecordingSettings, isPresented: $isPresented) .zIndex(20) .padding(.bottom, -12) .padding(.top, 6) .padding([.leading, .trailing], 8) if !goals.isEmpty { VStack(spacing: 16) { TitleView(activityType: activityType, goalType: goal.type) GoalTypeSelector(types: goals.map { $0.type }, selectedIdx: $selectedGoalTypeIdx) .zIndex(20) .padding([.leading, .trailing], -21) SetTargetView(goal: goal, howWeCalculateModel: howWeCalculatePopupModel) .animation(.none) .modifier(TargetOpacityAnimation(progress: selectedGoalTypeIdx > 0 ? 1.0 : 0.0)) Button { generator.impactOccurred() isPresented = false presentationMode.wrappedValue.dismiss() } label: { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(UIColor.adOrangeLighter)) Text("Set Goal") .foregroundColor(.black) .semibold() } .frame(height: 56) .maxWidth(.infinity) } .padding(.top, 2) .zIndex(20) } .padding(.bottom, 15) .padding([.leading, .trailing], 21) } } .background( Color(white: 0.05) .cornerRadius(35, corners: [.topLeft, .topRight]) .ignoresSafeArea() ) } } .background(Color.clear) .onAppear { goals = NSUbiquitousKeyValueStore.default.goals(for: activityType) goals.forEach { goal in goal.unit = ADUser.current.distanceUnit } selectedGoalTypeIdx = goals.firstIndex(where: { $0.type == goal.type }) ?? 0 goals[selectedGoalTypeIdx] = goal if !NSUbiquitousKeyValueStore.default.hasSetBodyMass && ADUser.current.totalDistanceTracked > 0.0 { showingWeightEntryView = true } hasSetup = true } .onDisappear { NSUbiquitousKeyValueStore.default.setGoals(goals, for: activityType) NSUbiquitousKeyValueStore.default.setSelectedGoalIdx(selectedGoalTypeIdx, for: activityType) Analytics.logEvent("Dismiss", screenName, .buttonTap) } .onChange(of: selectedGoalTypeIdx) { [selectedGoalTypeIdx] newValue in guard hasSetup else { return } goal = goal(for: newValue) goal.objectWillChange.send() prevSelectedGoalTypeIdx = selectedGoalTypeIdx if howWeCalculatePopupModel.statCalculationInfoVisible { howWeCalculatePopupModel.showStatCalculation(for: goals[newValue].type.statisticType) } } .fullScreenCover(isPresented: $showingWeightEntryView) { WeightEntryView() .background(BackgroundClearView()) } .fullScreenCover(isPresented: $showingRecordingSettings) { RecordingSettingsView() .background(BackgroundClearView()) } } }

This fun retro goal picker UI was made entirely in SwiftUI. The staggered chevron animation is my favorite part – it really makes you want to scroll with your finger.

3D Medals

import UIKit import SceneKit import SceneKit.ModelIO import CoreImage import ARKit class Collectible3DView: SCNView, CollectibleSCNView { var collectible: Collectible? var localUsdzUrl: URL? var collectibleEarned: Bool = true var engraveInitials: Bool = true var sceneStartTime: TimeInterval? var latestTime: TimeInterval = 0 var itemNode: SCNNode? var isSetup: Bool = false var defaultCameraDistance: Float = 50.0 internal var assetLoadTask: Task<(), Never>? var placeholderImageView: UIImageView? override func willMove(toSuperview newSuperview: UIView?) { super.willMove(toSuperview: newSuperview) scene?.background.contents = UIColor.clear backgroundColor = .clear if newSuperview != nil { SceneKitCleaner.shared.add(self) } } func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { sceneStartTime = sceneStartTime ?? time latestTime = time if isPlaying, let startTime = sceneStartTime { sceneTime = time - startTime } } func cleanupOrSetupIfNecessary() { let frameInWindow = self.convert(self.frame, to: nil) guard let windowBounds = self.window?.bounds else { if self.isSetup { self.cleanup() self.alpha = 0 self.isSetup = false } return } let isVisible = frameInWindow.intersects(windowBounds) if isVisible, !self.isSetup, let localUsdzUrl = self.localUsdzUrl { self.isSetup = true self.setup(withLocalUsdzUrl: localUsdzUrl) } if !isVisible && self.isSetup { self.cleanup() self.alpha = 0 self.isSetup = false } } } class CollectibleARSCNView: GestureARView, CollectibleSCNView { var collectible: Collectible? var localUsdzUrl: URL? var collectibleEarned: Bool = true var engraveInitials: Bool = true var sceneStartTime: TimeInterval? var latestTime: TimeInterval = 0 var itemNode: SCNNode? var isSetup: Bool = false var defaultCameraDistance: Float = 50.0 internal var assetLoadTask: Task<(), Never>? var placeholderImageView: UIImageView? override var nodeToMove: SCNNode? { return itemNode } override var distanceToGround: Float { if let collectible = collectible { switch collectible.type { case .remote(let remote): if !remote.shouldFloatInAR { return 0 } default: break } } return super.distanceToGround } override func getShadowImage(_ completion: @escaping (UIImage?) -> Void) { switch collectible?.itemType { case .medal: completion(UIImage(named: "medal_shadow")) case .foundItem: completion(UIImage(named: "item_shadow")) default: completion(nil) } } func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { sceneStartTime = sceneStartTime ?? time latestTime = time if isPlaying, let startTime = sceneStartTime { sceneTime = time - startTime } } func initialSceneLoaded() {} } protocol CollectibleSCNView: SCNView, SCNSceneRendererDelegate { var collectible: Collectible? { get set } var localUsdzUrl: URL? { get set } var collectibleEarned: Bool { get set } var engraveInitials: Bool { get set } var sceneStartTime: TimeInterval? { get set } var latestTime: TimeInterval { get set } var itemNode: SCNNode? { get set } var isSetup: Bool { get set } var defaultCameraDistance: Float { get set } var assetLoadTask: Task<(), Never>? { get set } var placeholderImageView: UIImageView? { get set } } extension CollectibleSCNView { func setup(withCollectible collectible: Collectible, earned: Bool, engraveInitials: Bool) { self.collectible = collectible self.collectibleEarned = earned self.engraveInitials = engraveInitials self.localUsdzUrl = nil switch collectible.itemType { case .medal: if let medalUsdzUrl = Bundle.main.url(forResource: "medal", withExtension: "scn") { self.localUsdzUrl = medalUsdzUrl self.setup(withLocalUsdzUrl: medalUsdzUrl) } case .foundItem: switch collectible.type { case .remote(let remote): if let usdzUrl = remote.usdzUrl { self.loadRemoteUsdz(atUrl: usdzUrl) } default: break } } } func setupForReusableView(withCollectible collectible: Collectible, earned: Bool = true) { guard collectible != self.collectible || self.collectibleEarned != earned else { return } self.preferredFramesPerSecond = 30 self.reset() self.cleanup() self.collectible = collectible self.collectibleEarned = earned self.localUsdzUrl = nil switch collectible.type { case .remote(let remote): if let usdzUrl = remote.usdzUrl { self.alpha = 0.0 loadRemoteUsdz(atUrl: usdzUrl) } default: break } } fileprivate func addPlaceholder() { DispatchQueue.main.async { self.placeholderImageView?.removeFromSuperview() self.placeholderImageView = UIImageView(image: UIImage(systemName: "shippingbox", withConfiguration: UIImage.SymbolConfiguration(weight: .light))) self.placeholderImageView?.alpha = 0.3 self.placeholderImageView?.contentMode = .scaleAspectFit self.placeholderImageView?.tintColor = .white self.superview?.addSubview(self.placeholderImageView!) self.placeholderImageView?.autoPinEdge(.leading, to: .leading, of: self) self.placeholderImageView?.autoPinEdge(.trailing, to: .trailing, of: self) self.placeholderImageView?.autoPinEdge(.top, to: .top, of: self) self.placeholderImageView?.autoPinEdge(.bottom, to: .bottom, of: self) } } private func loadRemoteUsdz(atUrl url: URL) { if !CollectibleDataCache.hasLoadedItem(atUrl: url) { addPlaceholder() } assetLoadTask?.cancel() assetLoadTask = Task(priority: .userInitiated) { let localUrl = await CollectibleDataCache.loadItem(atUrl: url) if let localUrl = localUrl, !Task.isCancelled { self.localUsdzUrl = localUrl self.setup(withLocalUsdzUrl: localUrl) } } } func setup(withLocalUsdzUrl url: URL) { Task { if let scene = await SceneLoader.loadScene(atUrl: url) { DispatchQueue.main.async { self.isSetup = true self.load(scene) } } } } func load(_ loadedScene: SCNScene) { #if targetEnvironment(simulator) return #endif CustomFiltersVendor.registerFilters() cleanup() if !(self is ARSCNView) { self.alpha = 0 backgroundColor = .clear } self.scene = loadedScene if !(self is ARSCNView) { scene?.background.contents = UIColor.clear } allowsCameraControl = true defaultCameraController.interactionMode = .orbitTurntable defaultCameraController.minimumVerticalAngle = -0.01 defaultCameraController.maximumVerticalAngle = 0.01 autoenablesDefaultLighting = true if (collectible?.itemType ?? .foundItem) != .medal && !(self is ARSCNView) { pointOfView = SCNNode() pointOfView?.camera = SCNCamera() scene?.rootNode.addChildNode(pointOfView!) } if !(self is ARSCNView) { pointOfView?.camera?.wantsHDR = true pointOfView?.camera?.wantsExposureAdaptation = false pointOfView?.camera?.exposureAdaptationBrighteningSpeedFactor = 20 pointOfView?.camera?.exposureAdaptationDarkeningSpeedFactor = 20 pointOfView?.camera?.motionBlurIntensity = 0.5 switch collectible?.type { case .remote(let remote): pointOfView?.camera?.bloomIntensity = CGFloat(remote.bloomIntensity) pointOfView?.position.z = remote.cameraDistance default: pointOfView?.camera?.bloomIntensity = 0.7 pointOfView?.position.z = defaultCameraDistance } pointOfView?.camera?.bloomBlurRadius = 5 pointOfView?.camera?.contrast = 0.5 pointOfView?.camera?.saturation = self.collectibleEarned ? 1.05 : 0 if collectible == nil { pointOfView?.camera?.focalLength = 40 } } antialiasingMode = .multisampling4X if !(self is ARSCNView) { let pinchGR = UIPinchGestureRecognizer(target: nil, action: nil) addGestureRecognizer(pinchGR) let panGR = UIPanGestureRecognizer(target: nil, action: nil) panGR.minimumNumberOfTouches = 2 panGR.maximumNumberOfTouches = 2 addGestureRecognizer(panGR) } if collectible?.itemType == .medal { let medalNode = scene?.rootNode.childNodes.first?.childNodes.first?.childNodes.first?.childNodes.first itemNode = medalNode medalNode?.opacity = 0 Task { let diffuseTexture = await generateTexture(fromCollectible: collectible!, engraveInitials: self.engraveInitials) DispatchQueue.main.async { var roughnessTexture = self.exposureAdjustedImage(diffuseTexture) let metalnessTexture = diffuseTexture if self.collectible!.medalImageHasBlackBackground { let roughnessCIImage = CIImage(cgImage: diffuseTexture.cgImage!) let invertedRoughness = roughnessCIImage .applyingFilter("CIExposureAdjust", parameters: [kCIInputEVKey: 4]) .applyingFilter("CIColorInvert") roughnessTexture = UIImage(ciImage: invertedRoughness).resized(withNewWidth: 1024, imageScale: 1) } medalNode?.geometry?.materials.first?.diffuse.contents = diffuseTexture medalNode?.geometry?.materials.first?.roughness.contents = roughnessTexture medalNode?.geometry?.materials.first?.metalness.contents = metalnessTexture medalNode?.geometry?.materials.first?.metalness.intensity = 1 medalNode?.geometry?.materials.first?.emission.contents = diffuseTexture medalNode?.geometry?.materials.first?.emission.intensity = 0.15 medalNode?.geometry?.materials.first?.selfIllumination.contents = diffuseTexture let normalTexture = CIImage(cgImage: roughnessTexture.cgImage!).applyingFilter("NormalMap") let normalImage = UIImage(ciImage: normalTexture).resized(withNewWidth: 1024, imageScale: 1) medalNode?.geometry?.materials.first?.normal.contents = normalImage if !(self is ARSCNView) { let opacityAction = SCNAction.fadeOpacity(by: 1, duration: 0.2) medalNode?.runAction(opacityAction) } } } } else { itemNode = scene?.rootNode.childNode(withName: "found_item_node", recursively: true) ?? scene?.rootNode.childNodes[safe: 0] //.childNodes[0].childNodes[0].childNodes[0].childNodes[0] } let spin = CABasicAnimation(keyPath: "rotation") spin.fromValue = NSValue(scnVector4: SCNVector4(x: 0, y: 1, z: 0, w: 0)) spin.toValue = NSValue(scnVector4: SCNVector4(x: 0, y: 1, z: 0, w: 2 * .pi)) spin.duration = 6 spin.repeatCount = .infinity spin.usesSceneTimeBase = true delegate = self if self is ARSCNView { allowsCameraControl = false if collectible?.itemType == .medal { itemNode?.scale = SCNVector3(0.02, 0.02, 0.02) } else if let itemNode = itemNode { let desiredHeight: Float = 0.4 let currentHeight = max(abs(itemNode.boundingBox.max.y - itemNode.boundingBox.min.y), abs(itemNode.boundingBox.max.x - itemNode.boundingBox.min.x), abs(itemNode.boundingBox.max.z - itemNode.boundingBox.min.z)) let scale = desiredHeight / currentHeight itemNode.scale = SCNVector3(scale, scale, scale) } itemNode?.position.z -= 1 itemNode?.position.y -= 1 switch collectible?.type { case .remote(let remote): if remote.shouldSpinInAR { itemNode?.addAnimation(spin, forKey: "rotation") } default: itemNode?.addAnimation(spin, forKey: "rotation") } (self as? GestureARView)?.addShadow() itemNode?.opacity = 0 } else if let itemNode = itemNode { if (collectible?.itemType ?? .foundItem) != .medal { let desiredHeight: Float = 30 let currentHeight = max(abs(itemNode.boundingBox.max.y - itemNode.boundingBox.min.y), abs(itemNode.boundingBox.max.x - itemNode.boundingBox.min.x), abs(itemNode.boundingBox.max.z - itemNode.boundingBox.min.z)) let scale = desiredHeight / currentHeight itemNode.scale = SCNVector3(scale, scale, scale) let centerY = (itemNode.boundingBox.min.y + itemNode.boundingBox.max.y) / 2 let centerX = (itemNode.boundingBox.min.x + itemNode.boundingBox.max.x) / 2 let centerZ = (itemNode.boundingBox.min.z + itemNode.boundingBox.max.z) / 2 itemNode.position = SCNVector3(-1 * centerX * scale, -1 * centerY * scale, -1 * centerZ * scale) pointOfView?.position.x = 0 pointOfView?.position.y = 0 } itemNode.addAnimation(spin, forKey: "rotation") scene?.rootNode.rotation = SCNVector4(x: 0, y: 1, z: 0, w: 1.75 * .pi) isPlaying = true UIView.animate(withDuration: 0.2) { self.alpha = self.collectibleEarned ? 1.0 : 0.45 } } UIView.animate(withDuration: 0.2) { self.placeholderImageView?.alpha = 0.0 } completion: { finished in self.placeholderImageView?.removeFromSuperview() self.placeholderImageView = nil } (self as? CollectibleARSCNView)?.initialSceneLoaded() } func cleanup() { isPlaying = false scene?.isPaused = true scene?.rootNode.cleanup() itemNode = nil delegate = nil scene = nil } func reset() { collectible = nil localUsdzUrl = nil } private func generateTexture(fromCollectible collectible: Collectible, engraveInitials: Bool = true, backgroundColor: UIColor = .black) async -> UIImage { let medalImage = await collectible.medalImage let borderColor = await collectible.medalBorderColor let medalBackImage = await generateBackImage(forCollectible: collectible, engraveInitials: engraveInitials) let options = UIGraphicsImageRendererFormat() options.opaque = true options.scale = 1 let size = CGSize(width: 1024, height: 1024) let renderer = UIGraphicsImageRenderer(size: size, format: options) return renderer.image { ctx in backgroundColor.setFill() ctx.fill(CGRect(origin: .zero, size: size)) let medalImgSize = CGSize(width: 512, height: 884) medalImage?.draw(in: CGRect(origin: CGPoint(x: 512, y: 0), size: medalImgSize) .insetBy(dx: 8, dy: 5) .offsetBy(dx: 0, dy: 5)) let backRect = CGRect(origin: .zero, size: medalImgSize) .insetBy(dx: 8, dy: 5) .offsetBy(dx: 0, dy: 5) medalBackImage?.sd_flippedImage(withHorizontal: true, vertical: true)?.draw(in: backRect) if let borderColor = borderColor { let borderFrame = CGRect(x: 0, y: 892, width: 716, height: 132) borderColor.setFill() ctx.fill(borderFrame) } } } private func generateBackImage(forCollectible collectible: Collectible, engraveInitials: Bool) async -> UIImage? { guard let medalImage = await collectible.medalImage, let borderColor = await collectible.medalBorderColor else { return nil } let isDarkBorder = borderColor.isBrightnessUnder(0.3) let medalBackImage = isDarkBorder ? UIImage(named: "medal_back_white")! : UIImage(named: "medal_back")! let textColor = isDarkBorder ? UIColor(white: 0.65, alpha: 1) : UIColor(white: 0.35, alpha: 1) let options = UIGraphicsImageRendererFormat() options.opaque = true options.scale = 1 let size = medalImage.size let renderer = UIGraphicsImageRenderer(size: size, format: options) let style = NSMutableParagraphStyle() style.alignment = .center style.lineBreakMode = .byWordWrapping return renderer.image { ctx in borderColor.setFill() let rect = CGRect(origin: .zero, size: size) ctx.fill(rect) medalBackImage.draw(in: rect) if engraveInitials { // Initial text let initialText = NSString(string: ADUser.current.initials) let initialFont = UIFont.presicav(size: 95, weight: .heavy) let initialAttributes: [NSAttributedString.Key : Any] = [.font: initialFont, .paragraphStyle: style, .foregroundColor: textColor] let initialTextSize = initialText.size(withAttributes: initialAttributes) let initialRect = CGRect(x: size.width / 2 - initialTextSize.width / 2, y: size.height / 2 - initialTextSize.height - 20, width: initialTextSize.width, height: initialTextSize.height) initialText.draw(in: initialRect, withAttributes: initialAttributes) // Earned Text let earnedDate = collectible.dateEarned.formatted(withStyle: .medium) let earnedText = NSString(string: "Earned on \(earnedDate)").uppercased let earnedFont = UIFont.presicav(size: 24) let earnedAttributes: [NSAttributedString.Key : Any] = [.font: earnedFont, .paragraphStyle: style, .foregroundColor: textColor, .kern: 3] let lrMargin: CGFloat = 100 let earnedRect = CGRect(x: lrMargin, y: size.height / 2 + 20, width: size.width - lrMargin * 2, height: 300) earnedText.draw(in: earnedRect, withAttributes: earnedAttributes) } else { let unearnedText = NSString(string: "#anydistancecounts").uppercased let font = UIFont.presicav(size: 24) let attributes: [NSAttributedString.Key : Any] = [.font: font, .paragraphStyle: style, .foregroundColor: textColor, .kern: 3] let lrMargin: CGFloat = 100 let rect = CGRect(x: lrMargin, y: size.height / 2 + 20, width: size.width - lrMargin * 2, height: 300) unearnedText.draw(in: rect, withAttributes: attributes) } } } func exposureAdjustedImage(_ image: UIImage) -> UIImage { let filter = CIFilter(name: "CIColorControls") let ciInputImage = CIImage(cgImage: image.cgImage!) filter?.setValue(ciInputImage, forKey: kCIInputImageKey) filter?.setValue(0.7, forKey: kCIInputContrastKey) filter?.setValue(-0.3, forKey: kCIInputBrightnessKey) if let output = filter?.outputImage { let context = CIContext() let cgOutputImage = context.createCGImage(output, from: ciInputImage.extent) return UIImage(cgImage: cgOutputImage!) } return image } } class NormalMapFilter: CIFilter { @objc dynamic var inputImage: CIImage? override var attributes: [String : Any] { return [ kCIAttributeFilterDisplayName: "Normal Map", "inputImage": [kCIAttributeIdentity: 0, kCIAttributeClass: "CIImage", kCIAttributeDisplayName: "Image", kCIAttributeType: kCIAttributeTypeImage] ] } let normalMapKernel = CIKernel(source: "float lumaAtOffset(sampler source, vec2 origin, vec2 offset)" + "{" + " vec3 pixel = sample(source, samplerTransform(source, origin + offset)).rgb;" + " float luma = dot(pixel, vec3(0.2126, 0.7152, 0.0722));" + " return luma;" + "}" + "kernel vec4 normalMap(sampler image) \n" + "{ " + " vec2 d = destCoord();" + " float northLuma = lumaAtOffset(image, d, vec2(0.0, -1.0));" + " float southLuma = lumaAtOffset(image, d, vec2(0.0, 1.0));" + " float westLuma = lumaAtOffset(image, d, vec2(-1.0, 0.0));" + " float eastLuma = lumaAtOffset(image, d, vec2(1.0, 0.0));" + " float horizontalSlope = ((westLuma - eastLuma) + 1.0) * 0.5;" + " float verticalSlope = ((northLuma - southLuma) + 1.0) * 0.5;" + " return vec4(horizontalSlope, verticalSlope, 1.0, 1.0);" + "}" ) override var outputImage: CIImage? { guard let inputImage = inputImage, let normalMapKernel = normalMapKernel else { return nil } return normalMapKernel.apply(extent: inputImage.extent, roiCallback: { (index, rect) in return rect }, arguments: [inputImage]) } } class CustomFiltersVendor: NSObject, CIFilterConstructor { func filter(withName name: String) -> CIFilter? { switch name { case "NormalMap": return NormalMapFilter() default: return nil } } static func registerFilters() { CIFilter.registerName("NormalMap", constructor: CustomFiltersVendor(), classAttributes: [ kCIAttributeFilterCategories: ["CustomFilters"] ]) } }

3D medals are one of the core parts of Any Distance, and we spent a lot of time getting them right. These are rendered in SceneKit, an older but highly versatile native 3D rendering library. The base medal is a .usdz model that gets textured at runtime, so we only need to store a single usdz.

Texturing is highly dynamic and configurable. The app generates textures for diffuse, metalness, roughness, and a normal map. We also inscribe the back of the medal with the user's name and the date they earned it.

We divided the texturing pipeline into two types of medals – ones that have black backgrounds and ones that don't. Ones with black backgrounds tend to look good with highly metallic textures on light parts. Ones without black backgrounds look better with the opposite behavior.

3D Sneakers

import Foundation import SceneKit import SceneKit.ModelIO import CoreImage import ARKit class Gear3DView: SCNView, SCNSceneRendererDelegate { var localUsdzUrl: URL? var sceneStartTime: TimeInterval? var latestTime: TimeInterval = 0 var itemNode: SCNNode? var isSetup: Bool = false var defaultCameraDistance: Float = 50.0 var color: GearColor = .white internal var assetLoadTask: Task<(), Never>? override func willMove(toSuperview newSuperview: UIView?) { super.willMove(toSuperview: newSuperview) scene?.background.contents = UIColor.clear backgroundColor = .clear if newSuperview != nil { SceneKitCleaner.shared.add(self) } } func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { sceneStartTime = sceneStartTime ?? time latestTime = time if isPlaying, let startTime = sceneStartTime { sceneTime = time - startTime } } func cleanupOrSetupIfNecessary() { let frameInWindow = self.convert(self.frame, to: nil) guard let windowBounds = self.window?.bounds else { if self.isSetup { self.cleanup() self.alpha = 0 self.isSetup = false } return } let isVisible = frameInWindow.intersects(windowBounds) if isVisible, !self.isSetup, let localUsdzUrl = self.localUsdzUrl { self.isSetup = true self.setup(withLocalUsdzUrl: localUsdzUrl, color: color) } if !isVisible && self.isSetup { self.cleanup() self.alpha = 0 self.isSetup = false } } func setup(withLocalUsdzUrl url: URL, color: GearColor) { Task { self.localUsdzUrl = url if let scene = await SceneLoader.loadScene(atUrl: url) { DispatchQueue.main.async { self.isSetup = true self.color = color self.load(scene) } } } } func setColor(color: GearColor) { self.color = color let textureNode = itemNode?.childNode(withName: "Plane_2", recursively: true) Task(priority: .userInitiated) { if let cachedTexture = HealthDataCache.shared.texture(for: color) { textureNode?.geometry?.materials.first?.diffuse.contents = cachedTexture } else if let texture = self.generateSneakerTexture(forColor: color) { HealthDataCache.shared.cache(texture: texture, for: color) textureNode?.geometry?.materials.first?.diffuse.contents = texture } } } func load(_ loadedScene: SCNScene) { #if targetEnvironment(simulator) return #endif CustomFiltersVendor.registerFilters() cleanup() self.alpha = 0 backgroundColor = .clear self.scene = loadedScene scene?.background.contents = UIColor.clear allowsCameraControl = true defaultCameraController.interactionMode = .orbitTurntable defaultCameraController.minimumVerticalAngle = -0.01 defaultCameraController.maximumVerticalAngle = 0.01 autoenablesDefaultLighting = true pointOfView = SCNNode() pointOfView?.camera = SCNCamera() scene?.rootNode.addChildNode(pointOfView!) pointOfView?.camera?.wantsHDR = true pointOfView?.camera?.wantsExposureAdaptation = false pointOfView?.camera?.exposureAdaptationBrighteningSpeedFactor = 20 pointOfView?.camera?.exposureAdaptationDarkeningSpeedFactor = 20 pointOfView?.camera?.motionBlurIntensity = 0.5 pointOfView?.camera?.bloomIntensity = 0.7 pointOfView?.position.z = defaultCameraDistance pointOfView?.camera?.bloomBlurRadius = 5 pointOfView?.camera?.contrast = 0.5 pointOfView?.camera?.saturation = 1.05 pointOfView?.camera?.focalLength = 40 antialiasingMode = .multisampling4X // debugOptions = [.showBoundingBoxes, .renderAsWireframe, .showWorldOrigin] itemNode = scene?.rootNode.childNode(withName: "found_item_node", recursively: true) ?? scene?.rootNode.childNodes[safe: 0] setColor(color: color) let spin = CABasicAnimation(keyPath: "rotation") spin.fromValue = NSValue(scnVector4: SCNVector4(x: 0, y: 1, z: 0, w: 0)) spin.toValue = NSValue(scnVector4: SCNVector4(x: 0, y: 1, z: 0, w: 2 * .pi)) spin.duration = 6 spin.repeatCount = .infinity spin.usesSceneTimeBase = true delegate = self if let itemNode = itemNode { let desiredHeight: Float = 30 let currentHeight = max(abs(itemNode.boundingBox.max.y - itemNode.boundingBox.min.y), abs(itemNode.boundingBox.max.x - itemNode.boundingBox.min.x), abs(itemNode.boundingBox.max.z - itemNode.boundingBox.min.z)) let scale = desiredHeight / currentHeight itemNode.scale = SCNVector3(scale, scale, scale) let centerY = (itemNode.boundingBox.min.y + itemNode.boundingBox.max.y) / 2 let centerX = (itemNode.boundingBox.min.x + itemNode.boundingBox.max.x) / 2 let centerZ = (itemNode.boundingBox.min.z + itemNode.boundingBox.max.z) / 2 itemNode.position = SCNVector3(-1 * centerX * scale, -1 * centerY * scale, -1 * centerZ * scale) pointOfView?.position.x = 0 pointOfView?.position.y = 0 itemNode.addAnimation(spin, forKey: "rotation") } scene?.rootNode.rotation = SCNVector4(x: 0, y: 1, z: 0, w: 1.75 * .pi) isPlaying = true UIView.animate(withDuration: 0.2) { self.alpha = 1.0 } } func cleanup() { isPlaying = false scene?.isPaused = true scene?.rootNode.cleanup() itemNode = nil delegate = nil scene = nil } func reset() { localUsdzUrl = nil } private func generateSneakerTexture(forColor color: GearColor) -> UIImage? { let format = UIGraphicsImageRendererFormat() format.scale = 1.0 format.opaque = true let size = CGSize(width: 1024.0, height: 1024.0) let renderer = UIGraphicsImageRenderer(size: size, format: format) return renderer.image { ctx in color.mainColor.setFill() ctx.fill(CGRect(origin: .zero, size: size)) let texture1 = UIImage(named: "texture_sneaker_1")?.withTintColor(color.accent1) texture1?.draw(at: .zero) let texture2 = UIImage(named: "texture_sneaker_2")?.withTintColor(color.accent2) texture2?.draw(at: .zero) let texture3 = UIImage(named: "texture_sneaker_3")?.withTintColor(color.accent3) texture3?.draw(at: .zero) let texture4 = UIImage(named: "texture_sneaker_4")?.withTintColor(color.accent4) texture4?.draw(at: .zero) let texture5 = UIImage(named: "texture_sneaker_5")?.withTintColor(color.accent4) texture5?.draw(at: .zero) } } } extension UIColor { var rgbComponents: [UInt32] { var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 getRed(&red, green: &green, blue: &blue, alpha: &alpha) let redComponent = UInt32(red * 255) let greenComponent = UInt32(green * 255) let blueComponent = UInt32(blue * 255) return [redComponent, greenComponent, blueComponent] } }

Like the medals, these 3D sneakers are also textured at runtime. Each sneaker texture is 5 layers, which all get tinted independently with CoreGraphics, then combined to create the final texture.

Gradient Animation (Metal)

#include <metal_stdlib> using namespace metal; #include <SceneKit/scn_metal> struct NodeBuffer { float4x4 modelTransform; float4x4 modelViewTransform; float4x4 normalTransform; float4x4 modelViewProjectionTransform; }; struct VertexIn { float2 position; }; struct VertexOut { float4 position [[position]]; float time; float2 viewSize; int page; }; struct Uniforms { int page; }; /// Passthrough vertex shader vertex VertexOut gradient_animation_vertex(const device packed_float3* in [[ buffer(0) ]], constant float &time [[buffer(1)]], const device packed_float2* viewSize [[buffer(2)]], constant int &page [[buffer(3)]], unsigned int vid [[ vertex_id ]]) { VertexOut out; out.position = float4(in[vid], 1); out.time = time + (float)page * 10.; out.viewSize = float2(viewSize->x, viewSize->y); out.page = page; return out; } float noise1(float seed1, float seed2){ return( fract(seed1+12.34567* fract(100.*(abs(seed1*0.91)+seed2+94.68)* fract((abs(seed2*0.41)+45.46)* fract((abs(seed2)+757.21)* fract(seed1*0.0171)))))) * 1.0038 - 0.00185; } float noise2(float seed1, float seed2, float seed3){ float buff1 = abs(seed1+100.81) + 1000.3; float buff2 = abs(seed2+100.45) + 1000.2; float buff3 = abs(noise1(seed1, seed2)+seed3) + 1000.1; buff1 = (buff3*fract(buff2*fract(buff1*fract(buff2*0.146)))); buff2 = (buff2*fract(buff2*fract(buff1+buff2*fract(buff3*0.52)))); buff1 = noise1(buff1, buff2); return(buff1); } float noise3(float seed1, float seed2, float seed3) { float buff1 = abs(seed1+100.813) + 1000.314; float buff2 = abs(seed2+100.453) + 1000.213; float buff3 = abs(noise1(buff2, buff1)+seed3) + 1000.17; buff1 = (buff3*fract(buff2*fract(buff1*fract(buff2*0.14619)))); buff2 = (buff2*fract(buff2*fract(buff1+buff2*fract(buff3*0.5215)))); buff1 = noise2(noise1(seed2,buff1), noise1(seed1,buff2), noise1(seed3,buff3)); return(buff1); } /// Fragment shader for gradient animation fragment float4 gradient_animation_fragment(VertexOut in [[stage_in]]) { float2 st = in.position.xy/in.viewSize.xy; st = float2(tan(st.x), sin(st.y)); st.x += (sin(in.time/2.1)+2.0)*0.12*sin(sin(st.y*st.x+in.time/6.0)*8.2); st.y -= (cos(in.time/1.73)+2.0)*0.12*cos(st.x*st.y*5.1-in.time/4.0); float3 bg = float3(0.0); float3 color1; float3 color2; float3 color3; float3 color4; float3 color5; if (in.page == 0) { color1 = float3(252.0/255.0, 60.0/255.0, 0.0/255.0); color2 = float3(253.0/255.0, 0.0/255.0, 12.0/255.0); color3 = float3(26.0/255.0, 0.5/255.0, 6.0/255.0); color4 = float3(128.0/255.0, 0.0/255.0, 17.0/255.0); color5 = float3(255.0/255.0, 15.0/255.0, 8.0/255.0); } else if (in.page == 1) { color1 = float3(183.0/255.0, 246.0/255.0, 254.0/255.0); color2 = float3(50.0/255.0, 160.0/255.0, 251.0/255.0); color3 = float3(3.0/255.0, 79.0/255.0, 231.0/255.0); color4 = float3(1.0/255.0, 49.0/255.0, 161.0/255.0); color5 = float3(3.0/255.0, 12.0/255.0, 47.0/255.0); } else if (in.page == 2) { color1 = float3(102.0/255.0, 231.0/255.0, 255.0/255.0); color2 = float3(4.0/255.0, 207.0/255.0, 213.0/255.0); color3 = float3(0.0/255.0, 160.0/255.0, 119.0/255.0); color4 = float3(0.0/255.0, 175.0/255.0, 139.0/255.0); color5 = float3(2.0/255.0, 37.0/255.0, 27.0/255.0); } else { color1 = float3(255.0/255.0, 50.0/255.0, 134.0/255.0); color2 = float3(236.0/255.0, 18.0/255.0, 60.0/255.0); color3 = float3(178.0/255.0, 254.0/255.0, 0.0/255.0); color4 = float3(0.0/255.0, 248.0/255.0, 209.0/255.0); color5 = float3(0.0/255.0, 186.0/255.0, 255.0/255.0); } float mixValue = smoothstep(0.0, 0.8, distance(st,float2(sin(in.time/5.0)+0.5,sin(in.time/6.1)+0.5))); float3 outColor = mix(color1,bg,mixValue); mixValue = smoothstep(0.1, 0.9, distance(st,float2(sin(in.time/3.94)+0.7,sin(in.time/4.2)-0.1))); outColor = mix(color2,outColor,mixValue); mixValue = smoothstep(0.1, 0.8, distance(st,float2(sin(in.time/3.43)+0.2,sin(in.time/3.2)+0.45))); outColor = mix(color3,outColor,mixValue); mixValue = smoothstep(0.14, 0.89, distance(st,float2(sin(in.time/5.4)-0.3,sin(in.time/5.7)+0.7))); outColor = mix(color4,outColor,mixValue); mixValue = smoothstep(0.01, 0.89, distance(st,float2(sin(in.time/9.5)+0.23,sin(in.time/3.95)+0.23))); outColor = mix(color5,outColor,mixValue); /// ---- mixValue = smoothstep(0.01, 0.89, distance(st,float2(cos(in.time/8.5)/2.+0.13,sin(in.time/4.95)-0.23))); outColor = mix(color1,outColor,mixValue); mixValue = smoothstep(0.1, 0.9, distance(st,float2(cos(in.time/6.94)/2.+0.7,sin(in.time/4.112)+0.66))); outColor = mix(color2,outColor,mixValue); mixValue = smoothstep(0.1, 0.8, distance(st,float2(cos(in.time/4.43)/2.+0.2,sin(in.time/6.2)+0.85))); outColor = mix(color3,outColor,mixValue); mixValue = smoothstep(0.14, 0.89, distance(st,float2(cos(in.time/10.4)/2.-0.3,sin(in.time/5.7)+0.8))); outColor = mix(color4,outColor,mixValue); mixValue = smoothstep(0.01, 0.89, distance(st,float2(cos(in.time/4.5)/2.+0.63,sin(in.time/4.95)+0.93))); outColor = mix(color5,outColor,mixValue); float2 st_unwarped = in.position.xy/in.viewSize.xy; float3 noise = float3(noise3(st_unwarped.x*0.000001, st_unwarped.y*0.000001, in.time * 1e-15)); outColor = (outColor * 0.85) - (noise * 0.1); return float4(outColor, 1.0); }

This gradient animation shader was written in Metal. Although the implementation is completely different than the SwiftUI version, it follows the same basic principle – lots of blurred circles with random sizes, positions, animation, and blurs. It's really easy to draw a blurred oval in a shader – just ramp the color according to the distance to a center point. On top of that, there's some subtle domain warping and a generative noise function to really make it pop.

Tap & Hold To Stop Animation

fileprivate struct TapAndHoldToStopView: View { @Binding var isPressed: Bool @State private var isVisible: Bool = false var drawerClosedHeight: CGFloat var body: some View { VStack { ZStack { Group { DarkBlurView() .mask(RoundedRectangle(cornerRadius: 30)) ToastText(text: "Tap and hold to stop", icon: Image(systemName: .stopFill), iconIncludesCircle: false) } ZStack { BlurView(style: .systemUltraThinMaterialLight, intensity: 0.3) .brightness(0.2) .mask(RoundedRectangle(cornerRadius: 30)) ToastText(text: "Tap and hold to stop", icon: Image(systemName: .stopFill), iconIncludesCircle: false, foregroundColor: .black) } .mask { GeometryReader { geo in HStack { Rectangle() .frame(width: isPressed ? geo.size.width : 0) Spacer() } } } } .frame(width: 260) .opacity(isVisible ? 1.0 : 0.0) .scaleEffect(isVisible ? 1.0 : 1.1) .frame(height: 60) .onChange(of: isPressed) { _ in withAnimation(isPressed ? .easeOut(duration: 0.15) : .easeIn(duration: 0.15)) { isVisible = isPressed } } Spacer() .height(drawerClosedHeight) } .ignoresSafeArea() } }

This animation uses two instances of the "Tap and hold to stop" text - one in black and one in white. The black text gets masked by the progress bar as it slides over the screen, ensuring the text is legible no matter where the progress bar is.

Access Code Field

import SwiftUI fileprivate struct GradientAnimation: View { @Binding var animate: Bool private func rand18(_ idx: Int) -> [Float] { let idxf = Float(idx) return [sin(idxf * 6.3), cos(idxf * 1.3 + 48), sin(idxf + 31.2), cos(idxf * 44.1), sin(idxf * 3333.2), cos(idxf + 1.12 * pow(idxf, 3)), sin(idxf * 22), cos(idxf * 34)] } var body: some View { ZStack { ForEach(Array(0...50), id: \.self) { idx in let rands = rand18(idx) let fill = Color(hue: sin(Double(idx) * 5.12) + 1.1, saturation: 1, brightness: 1) Ellipse() .fill(fill) .frame(width: CGFloat(rands[1] + 2.0) * 50.0, height: CGFloat(rands[2] + 2.0) * 40.0) .blur(radius: 25.0 + CGFloat(rands[1] + rands[2]) / 2) .opacity(0.8) .offset(x: CGFloat(animate ? rands[3] * 150.0 : rands[4] * 150.0), y: CGFloat(animate ? rands[5] * 50.0 : rands[6] * 50.0)) .animation(.easeInOut(duration: TimeInterval(rands[7] + 3.0) * 1.3).repeatForever(autoreverses: true), value: animate) } } .offset(y: 0) .onAppear { animate = true } } } struct AccessCodeField: View { @Binding var accessCode: String var isFocused: FocusState<Bool>.Binding @State private var chars: [String] = [String](repeating: " ", count: 6) @State private var animateGradient: Bool = false @State private var showingPasteButton: Bool = false fileprivate struct CharacterText: View { @Binding var text: String var cursorVisible: Bool @State private var animateCursor: Bool = false var body: some View { ZStack { Text(text) .multilineTextAlignment(.center) .font(.system(size: 29, weight: .light, design: .monospaced)) .foregroundColor(.white) .maxHeight(.infinity) .allowsHitTesting(false) RoundedRectangle(cornerRadius: 1.5) .frame(width: 3) .frame(height: 40) .opacity(animateCursor ? 1 : 0) .opacity(cursorVisible ? 1 : 0) .animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: animateCursor) } .onAppear { animateCursor = true } } } struct GridSeparator: View { var body: some View { VStack(spacing: 0) { Rectangle() .fill(LinearGradient(colors: [.black, .clear, .clear, .black], startPoint: .leading, endPoint: .trailing)) .frame(height: 2) HStack(spacing: 0) { Group { Rectangle() .fill(Color.black) Rectangle() .fill(Color.black.opacity(0.3)) .frame(width: 2) Rectangle() .fill(Color.black) Rectangle() .fill(Color.clear) .frame(width: 2) Rectangle() .fill(Color.black) Rectangle() .fill(Color.clear) .frame(width: 2) } Group { Rectangle() .fill(Color.black) Rectangle() .fill(Color.clear) .frame(width: 2) Rectangle() .fill(Color.black) Rectangle() .fill(Color.black.opacity(0.3)) .frame(width: 2) Rectangle() .fill(Color.black) } } } } } var pasteButton: some View { Button { accessCode = String(UIPasteboard.general.string?.prefix(6) ?? "") showingPasteButton = false } label: { ZStack { VStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(white: 0.2)) .frame(width: 75, height: 40) Rectangle() .rotation(.degrees(45)) .fill(Color(white: 0.2)) .frame(width: 20, height: 20) .offset(y: -25) } Text("Paste") .font(.system(size: 16, weight: .regular, design: .default)) .foregroundColor(.white) .offset(y: -14) } } .opacity(showingPasteButton ? 1 : 0) .animation(.easeInOut(duration: 0.2), value: showingPasteButton) .offset(y: -45) } var body: some View { GeometryReader { geo in ZStack { Color.black Image(uiImage: UIImage(named: "dot_bg")!.resized(withNewWidth: 4)) .resizable(resizingMode: .tile) .opacity(0.15) .mask { GridSeparator() } GradientAnimation(animate: $animateGradient) .frame(height: 60.0) .frame(maxWidth: .infinity) .drawingGroup() .saturation(1.2) .brightness(0.1) .mask { LinearGradient(colors: [.black, .black.opacity(0.1)], startPoint: .top, endPoint: .bottom) } .onChange(of: isFocused.wrappedValue) { _ in animateGradient = !animateGradient } GridSeparator() .opacity(0.45) TextField("", text: $accessCode) .disableAutocorrection(true) .textInputAutocapitalization(.characters) .focused(isFocused) .opacity(0.01) HStack(spacing: 0) { ForEach(Array(0...5), id: \.self) { idx in CharacterText(text: .constant("0"), cursorVisible: false) .frame(width: geo.size.width / 6, height: 60) } } .opacity((isFocused.wrappedValue || !accessCode.isEmpty) ? 0.0 : 0.4) .allowsHitTesting(false) HStack(spacing: 0) { ForEach(Array(0...5), id: \.self) { idx in CharacterText(text: $chars[idx], cursorVisible: accessCode.count == idx && isFocused.wrappedValue) .frame(width: geo.size.width / 6.0, height: 60.0) } } .contentShape(Rectangle()) .onLongPressGesture(minimumDuration: 0.4) { showingPasteButton = true } .simultaneousGesture(TapGesture().onEnded({ _ in accessCode = "" isFocused.wrappedValue = true showingPasteButton = false })) } } .frame(height: 60.0) .mask { RoundedRectangle(cornerRadius: 12.0, style: .continuous) } .background { RoundedRectangle(cornerRadius: 12.0, style: .continuous) .fill(Color.white.opacity(0.25)) .offset(y: 1.0) } .overlay { pasteButton } .onChange(of: accessCode) { newValue in if newValue.count >= 6 { isFocused.wrappedValue = false } showingPasteButton = false chars = newValue.padding(toLength: 6, withPad: " ", startingAt: 0).map { String($0).capitalized } } } } #Preview { @FocusState var focused: Bool AccessCodeField(accessCode: .constant(""), isFocused: $focused) }

This is a fun glowing text field written in SwiftUI. We used it when we announced early access for Active Clubs, our swing at a social network. The trick to the soupy rainbow effect is layering lots of ovals with varying size and opacity, animating them back and forth at various phases and durations, and blurring them random amounts. The video below shows what this looks like without the blur, which might give you a better idea of what's going on.

Custom MapKit Overlay Renderer

fileprivate struct MapView: UIViewRepresentable { @ObservedObject var model: ActivityProgressGraphModel @Binding var selectedClusterIdx: Int func makeUIView(context: Context) -> MKMapView { let mapView = MKMapView() mapView.mapType = .mutedStandard mapView.preferredConfiguration.elevationStyle = .flat mapView.isPitchEnabled = false mapView.showsUserLocation = false mapView.showsBuildings = false mapView.overrideUserInterfaceStyle = .dark mapView.pointOfInterestFilter = MKPointOfInterestFilter.excludingAll mapView.setUserTrackingMode(.none, animated: false) mapView.delegate = context.coordinator mapView.alpha = 0.0 context.coordinator.mkView = mapView model.$coordinateClusters .receive(on: DispatchQueue.main) .sink { _ in selectedClusterIdx = 0 }.store(in: &context.coordinator.subscribers) model.$viewVisible .receive(on: DispatchQueue.main) .sink { visible in if visible { addPolylines(mapView) } else { mapView.removeOverlays(mapView.overlays) } }.store(in: &context.coordinator.subscribers) return mapView } private func addPolylines(_ mapView: MKMapView) { if model.coordinateClusters.isEmpty { return } Task(priority: .userInitiated) { mapView.removeOverlays(mapView.overlays) model.coordinateClusters[selectedClusterIdx].coordinates.forEach { coordinates in coordinates.withUnsafeBufferPointer { pointer in if let base = pointer.baseAddress { let newPolyline = MKPolyline(coordinates: base, count: coordinates.count) mapView.addOverlay(newPolyline) } } } } } func updateUIView(_ uiView: MKMapView, context: Context) { if model.coordinateClusters.count > selectedClusterIdx { addPolylines(uiView) let rect = model.coordinateClusters[selectedClusterIdx].rect if rect != context.coordinator.displayedRect { let edgePadding = UIEdgeInsets(top: 180.0, left: 25.0, bottom: UIScreen.main.bounds.height * 0.4, right: 25.0) uiView.setVisibleMapRect(rect, edgePadding: edgePadding, animated: context.coordinator.hasSetInitialRegion) context.coordinator.displayedRect = rect context.coordinator.resetAnimationTimer() context.coordinator.hasSetInitialRegion = true } context.coordinator.shouldAnimateIn = true } else if model.coordinateClusters.isEmpty { uiView.removeOverlays(uiView.overlays) if context.coordinator.hasSetInitialRegion && model.hasPerformedInitialLoad { context.coordinator.shouldAnimateIn = true } } } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } final class Coordinator: NSObject, MKMapViewDelegate { private var parent: MapView var mkView: MKMapView? var subscribers: Set<AnyCancellable> = [] var hasSetInitialRegion: Bool = false var hasInitialFinishedRender: Bool = false var displayedRect: MKMapRect? var shouldAnimateIn: Bool = false var willRender: Bool = false private lazy var displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFire)) private var polylineProgress: CGFloat = 0 private let lineColor = UIColor.white.withAlphaComponent(0.6) init(parent: MapView) { self.parent = parent super.init() self.displayLink.add(to: .main, forMode: .common) self.displayLink.add(to: .main, forMode: .tracking) self.displayLink.isPaused = false } func resetAnimationTimer() { polylineProgress = -0.05 displayLinkFire() displayLink.isPaused = true } @objc func displayLinkFire() { if polylineProgress <= 1 { for overlay in mkView!.overlays { if !overlay.boundingMapRect.intersects(mkView?.visibleMapRect ?? MKMapRect()) { continue } if let polylineRenderer = mkView!.renderer(for: overlay) as? MKPolylineRenderer { polylineRenderer.strokeEnd = RouteScene.easeOutQuad(x: polylineProgress).clamped(to: 0...1) polylineRenderer.strokeColor = polylineProgress <= 0.01 ? .clear : lineColor polylineRenderer.blendMode = .destinationAtop polylineRenderer.setNeedsDisplay() } } polylineProgress += 0.01 } } func lineWidth(for mapView: MKMapView) -> CGFloat { let visibleWidth = mapView.visibleMapRect.width return CGFloat(-0.00000975 * visibleWidth + 2.7678715).clamped(to: 1.5...2.5) } func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { let stroke = lineWidth(for: mapView) if let routePolyline = overlay as? MKPolyline { let renderer = MKPolylineRenderer(polyline: routePolyline) renderer.strokeColor = displayLink.isPaused ? .clear : lineColor renderer.lineWidth = stroke renderer.strokeEnd = displayLink.isPaused ? 0 : 1 renderer.blendMode = .destinationAtop renderer.lineJoin = .round renderer.lineCap = .round return renderer } return MKOverlayRenderer() } func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) { let stroke = lineWidth(for: mapView) for overlay in mkView!.overlays { if !overlay.boundingMapRect.intersects(mkView?.visibleMapRect ?? MKMapRect()) { continue } if let polylineRenderer = mkView!.renderer(for: overlay) as? MKPolylineRenderer { polylineRenderer.lineWidth = stroke } } } func mapViewWillStartRenderingMap(_ mapView: MKMapView) { willRender = true } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { if willRender { return } displayLink.isPaused = false } func mapViewDidFinishRenderingMap(_ mapView: MKMapView, fullyRendered: Bool) { if fullyRendered { displayLink.isPaused = false willRender = false if shouldAnimateIn { UIView.animate(withDuration: 0.3) { mapView.alpha = 1.0 } shouldAnimateIn = false } } } } }

This was my attempt at animating polylines using only the native MapKit API. It's a bit of a hack – I use a CADisplayLink to force a redraw on every frame where I update each polyline renderer's strokeEnd property. This creates a beautiful sprawling animation as your route lines all fill out simultaneously.

Fake UIAlertView

import SwiftUI struct AppleHealthPermissionsView: View { @State var dismissing: Bool = false @State var isPresented: Bool = false var nextAction: (() -> Void)? var body: some View { ZStack { Color.black.opacity(0.4) .ignoresSafeArea() .opacity(isPresented ? 1 : 0) .animation(.easeInOut(duration: 0.2), value: isPresented) VStack(spacing: 0) { Text("Permissions for Apple Health") .padding([.leading, .trailing, .top], 16) .padding([.bottom], 4) .multilineTextAlignment(.center) .font(.system(size: 17, weight: .semibold)) Text("To sync your Activities, Any Distance needs permission to view your Apple Health data. The data is only read, it's never stored or shared elsewhere.\n\nOn the next screen, tap “Turn On All” and “Allow” to continue with your setup.") .padding([.leading, .trailing, .bottom], 16) .multilineTextAlignment(.center) .font(.system(size: 13)) LoopingVideoView(videoUrl: Bundle.main.url(forResource: "health-how-to", withExtension: "mp4"), videoGravity: .resizeAspect) .frame(width: UIScreen.main.bounds.width * 0.7, height: UIScreen.main.bounds.width * 0.55) .background(Color.white) Rectangle() .fill(Color.white.opacity(0.35)) .frame(height: 0.5) HStack(spacing: 0) { Button { dismissing = true isPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { UIApplication.shared.topViewController?.dismiss(animated: false) } } label: { Text("Cancel") .foregroundColor(.blue) .brightness(0.1) .frame(width: (UIScreen.main.bounds.width * 0.35) - 1, height: 46) } .buttonStyle(AlertButtonStyle()) Rectangle() .fill(Color.white.opacity(0.35)) .frame(width: 0.5, height: 46) Button { nextAction?() } label: { Text("Next") .foregroundColor(.blue) .brightness(0.1) .frame(width: (UIScreen.main.bounds.width * 0.35) - 1, height: 46) } .buttonStyle(AlertButtonStyle()) } } .background { BlurView() } .cornerRadius(14, style: .continuous) .frame(width: UIScreen.main.bounds.width * 0.7) .opacity(isPresented ? 1 : 0) .scaleEffect(dismissing ? 1 : (isPresented ? 1 : 1.15)) .animation(.easeOut(duration: 0.25), value: isPresented) } .onAppear { isPresented = true } } } struct AppleHealthPermissionsView_Previews: PreviewProvider { static var previews: some View { AppleHealthPermissionsView() } }

Is this allowed? We first designed this in Figma, thinking we could insert a video into a UIAlertView. Turns out you can't do that. So as a challenge, I tried to recreate UIAlertView as closely as possible in SwiftUI, but with the addition of a looping video view. The look is now outdated for iOS 26, but it was a fun test to see how far we could push SwiftUI to recreate native system UI.

Health Connect Animation (2021)

import UIKit class SyncAnimationView: UIView { // MARK: - Images let supportedActivitiesIcons = UIImage(named: "sync_supported_activities")! let sourceIcons: [UIImage] = [UIImage(named: "icon_garmin")!, UIImage(named: "icon_runkeeper")!, UIImage(named: "icon_peloton")!, UIImage(named: "icon_nrc")!, UIImage(named: "icon_strava")!, UIImage(named: "icon_fitness")!] let healthIcon = UIImage(named: "glyph_applehealth_100px")! let syncIcon = UIImage(named: "glyph_sync")! // MARK: - Colors let sourceColors: [UIColor] = [UIColor(hex: "00B0F3")!, UIColor(hex: "00CCDA")!, UIColor(hex: "FF003D")!, UIColor(hex: "464646")!, UIColor(hex: "FF5700")!, UIColor(hex: "CCFF00")!] let trackColor = UIColor(hex: "2B2B2B")! let healthDotColor = UIColor(hex: "FFC100")! // MARK: - Layout let headerHeight: CGFloat = 50 let sourceIconSize: CGFloat = 61 let sourceIconSpacing: CGFloat = 14 let sourceTrackSpacing: CGFloat = 10 let sourceStartY: CGFloat = 142 let trackWidth: CGFloat = 4.5 let trackCornerRadius: CGFloat = 32 let dotRadius: CGFloat = 7.5 var sourceHealthSpacing: CGFloat = 110 let healthIconSize: CGFloat = 100 let verticalLineLength: CGFloat = 400 let syncIconSize: CGFloat = 113 let iconCarouselSpeed: CGFloat = 0.5 let dotSpeed: CGFloat = 0.6 let dotSpawnRate: Int = 40 let verticalDotSpeed: CGFloat = 0.4 let verticalDotSpawnRate: Int = 120 let syncIconRotationRate: CGFloat = 0.05 let translateAnimationDuration: CGFloat = 1.5 var finalTranslateY: CGFloat = -650 // MARK: - Variables private var displayLink: CADisplayLink! private var t: Int = 0 private var dots: [Dot] = [] private var verticalDots: [VerticalDot] = [] private var dotSpawn: Int = 0 private var verticalDotSpawn: Int = 0 private var translateY: CGFloat = 0 private var syncIconRotate: CGFloat = 0 private var prevDotSpawnIdx: Int = 0 private var animProgress: CGFloat = 0 private var animatingTranslate: Bool = false // MARK: - Setup override func awakeFromNib() { super.awakeFromNib() // adjust spacing between source icons and health icon for smaller screens let sizeDiff = (844 - UIScreen.main.bounds.height).clamped(to: -30...60) sourceHealthSpacing -= sizeDiff finalTranslateY += sizeDiff // make dots spawn immediately dotSpawn = dotSpawnRate - 1 verticalDotSpawn = verticalDotSpawnRate - 30 layer.masksToBounds = false clipsToBounds = false displayLink = CADisplayLink(target: self, selector: #selector(update)) displayLink.preferredFramesPerSecond = 60 displayLink.add(to: .main, forMode: .common) } func animateTranslate() { animatingTranslate = true } func translateWithoutAnimating() { translateY = finalTranslateY } @objc private func update() { t += 1 incrementDots() spawnNewDots() spawnNewVerticalDots() setNeedsDisplay() if animatingTranslate { animProgress += 1 / translateAnimationDuration / 60 let easedProgress = easeInOutQuart(x: animProgress) translateY = easedProgress * finalTranslateY if easedProgress >= 1 { animatingTranslate = false } } } private func easeInOutQuart(x: CGFloat) -> CGFloat { return x < 0.5 ? 8 * pow(x, 4) : 1 - pow(-2 * x + 2, 4) / 2 } private func incrementDots() { var i = 0 while i < dots.count { dots[i].percent += dotSpeed / 100 dots[i].pathStartX -= iconCarouselSpeed if dots[i].percent >= 1 { dots.remove(at: i) } else { i += 1 } } i = 0 while i < verticalDots.count { verticalDots[i].percent += verticalDotSpeed / 100 if verticalDots[i].percent >= 1 { verticalDots.remove(at: i) } else { i += 1 } } } private func spawnNewDots() { guard dotSpawn == dotSpawnRate else { dotSpawn += 1 return } dotSpawn = 0 var i = 0 var startX = (-1 * iconCarouselSpeed * CGFloat(t)) - 150 while startX < 0 { startX += (sourceIconSize + sourceIconSpacing) i += 1 } var rand = prevDotSpawnIdx while rand == prevDotSpawnIdx { rand = Int(arc4random_uniform(5)) } prevDotSpawnIdx = rand startX += CGFloat(rand) * (sourceIconSize + sourceIconSpacing) startX += sourceIconSize / 2 i += rand let newDot = Dot(percent: 0, pathStartX: startX, color: sourceColors[i % sourceColors.count]) dots.append(newDot) } private func spawnNewVerticalDots() { guard verticalDotSpawn == verticalDotSpawnRate else { verticalDotSpawn += 1 return } verticalDotSpawn = 0 let newVerticalDot = VerticalDot(percent: 0) verticalDots.append(newVerticalDot) } // MARK: - Draw override func draw(_ rect: CGRect) { let ctx = UIGraphicsGetCurrentContext() ctx?.translateBy(x: 0, y: headerHeight) ctx?.translateBy(x: 0, y: translateY) // Draw Source Icons + Tracks + Dots var i = 0 var startX = (-1 * iconCarouselSpeed * CGFloat(t)) - 150 func inc() { i += 1 startX += (sourceIconSize + sourceIconSpacing) } let pathStartY: CGFloat = sourceStartY + sourceIconSize + sourceTrackSpacing let pathEndY: CGFloat = sourceStartY + sourceIconSize + sourceHealthSpacing + (healthIconSize / 2) trackColor.setStroke() // Draw horizonal line let horizontalPath = UIBezierPath() horizontalPath.lineCapStyle = .round horizontalPath.lineWidth = trackWidth horizontalPath.move(to: CGPoint(x: -20, y: pathEndY)) horizontalPath.addLine(to: CGPoint(x: bounds.width + 20, y: pathEndY)) horizontalPath.stroke() var sourceIconsToDraw: [UIImage] = [] var sourceIconRects: [CGRect] = [] ctx?.setShadow(offset: .zero, blur: 10, color: nil) while startX < rect.width + (2 * sourceIconSize) { if startX < -2 * sourceIconSize { inc() continue } // Draw Path let path = UIBezierPath() path.lineCapStyle = .round path.lineWidth = trackWidth path.move(to: CGPoint(x: startX + sourceIconSize / 2, y: pathStartY)) path.addLine(to: CGPoint(x: startX + sourceIconSize / 2, y: pathEndY - trackCornerRadius)) let isRightSide = (startX + sourceIconSize / 2) > (bounds.width / 2) let centerX = isRightSide ? startX + (sourceIconSize / 2) - trackCornerRadius : startX + (sourceIconSize / 2) + trackCornerRadius path.addArc(withCenter: CGPoint(x: centerX, y: pathEndY - trackCornerRadius), radius: trackCornerRadius, startAngle: isRightSide ? 0 : .pi, endAngle: .pi / 2, clockwise: isRightSide) path.stroke() // Queue source icon drawing for after we draw the dots let icon = sourceIcons[i % sourceIcons.count] let rect = CGRect(x: startX, y: sourceStartY, width: sourceIconSize, height: sourceIconSize) sourceIconsToDraw.append(icon) sourceIconRects.append(rect) inc() } // Draw Dots for dot in dots { let centerPoint = pointOnPath(forDot: dot) dot.color.setFill() ctx?.setShadow(offset: .zero, blur: 10, color: dot.color.cgColor) UIBezierPath(ovalIn: CGRect(x: centerPoint.x - dotRadius, y: centerPoint.y - dotRadius, width: dotRadius * 2, height: dotRadius * 2)).fill() } // Draw Source Icons ctx?.setShadow(offset: .zero, blur: 10, color: nil) for (icon, rect) in zip(sourceIconsToDraw, sourceIconRects) { icon.draw(in: rect) } // Draw vertical line let verticalPath = UIBezierPath() verticalPath.lineWidth = trackWidth verticalPath.move(to: CGPoint(x: bounds.width / 2, y: pathEndY)) verticalPath.addLine(to: CGPoint(x: bounds.width / 2, y: pathEndY + verticalLineLength)) verticalPath.stroke() // Draw vertical dots ctx?.setShadow(offset: .zero, blur: 10, color: healthDotColor.cgColor) for dot in verticalDots { let y = pathEndY + (verticalLineLength * dot.percent) let centerPoint = CGPoint(x: bounds.width / 2, y: y) let dotFrame = CGRect(x: centerPoint.x - dotRadius, y: centerPoint.y - dotRadius, width: dotRadius * 2, height: dotRadius * 2) healthDotColor.setFill() UIBezierPath(ovalIn: dotFrame).fill() } // Draw Health Icon ctx?.setShadow(offset: .zero, blur: 10, color: nil) let healthIconFrame = CGRect(x: (bounds.width / 2) - (healthIconSize / 2), y: pathEndY - (healthIconSize / 2), width: healthIconSize, height: healthIconSize) healthIcon.draw(in: healthIconFrame) // Draw Sync Icon ctx?.translateBy(x: (bounds.width / 2), y: (pathEndY + verticalLineLength)) ctx?.rotate(by: syncIconRotate) ctx?.translateBy(x: -1 * (syncIconSize / 2), y: -1 * (syncIconSize / 2)) syncIcon.draw(in: CGRect(origin: .zero, size: CGSize(width: syncIconSize, height: syncIconSize))) syncIconRotate += syncIconRotationRate } private func pointOnPath(forDot dot: Dot) -> CGPoint { let percent = dot.percent let isRightSide = dot.pathStartX > (bounds.width / 2) let centerX = isRightSide ? dot.pathStartX - trackCornerRadius : dot.pathStartX + trackCornerRadius let pathStartY: CGFloat = sourceStartY + (sourceIconSize / 2) let pathEndY: CGFloat = sourceStartY + sourceIconSize + sourceHealthSpacing + (healthIconSize / 2) let verticalLineLength = pathEndY - pathStartY - trackCornerRadius let curveLength = .pi * trackCornerRadius / 2 let horizontalLineLength = abs((bounds.width / 2) - centerX) let totalLineLength = verticalLineLength + curveLength + horizontalLineLength let vertPercent = percent / (verticalLineLength / totalLineLength) let curvePercent = (percent - (verticalLineLength / totalLineLength)) / (curveLength / totalLineLength) let horizontalPercent = (percent - ((verticalLineLength + curveLength) / totalLineLength)) / (horizontalLineLength / totalLineLength) var dotPoint: CGPoint = .zero if percent <= verticalLineLength / totalLineLength { // Dot is on vertical line dotPoint = CGPoint(x: dot.pathStartX, y: pathStartY + (verticalLineLength * vertPercent)) } else if percent <= (verticalLineLength + curveLength) / totalLineLength { // Dot is on curve if abs((bounds.width / 2) - dot.pathStartX) < healthIconSize / 3 { // Make dot go straight down if its under the Health icon dotPoint = CGPoint(x: bounds.width / 2, y: pathEndY) } else if isRightSide { let angle = ((.pi / 2) * (1 - curvePercent)) dotPoint = CGPoint(x: centerX + (trackCornerRadius * sin(angle)), y: (pathEndY - trackCornerRadius) + (trackCornerRadius * cos(angle))) } else { let angle = ((.pi / 2) * curvePercent) - (.pi / 2) dotPoint = CGPoint(x: centerX + (trackCornerRadius * sin(angle)), y: (pathEndY - trackCornerRadius) + (trackCornerRadius * cos(angle))) } } else { // Dot is on horizontal line if abs((bounds.width / 2) - dot.pathStartX) < healthIconSize / 3 { // Make dot go straight down if its under the Health icon dotPoint = CGPoint(x: bounds.width / 2, y: pathEndY) } else { let clampedPercent = horizontalPercent.clamped(to: 0...1) let x = isRightSide ? centerX - (clampedPercent * horizontalLineLength) : centerX + (clampedPercent * horizontalLineLength) dotPoint = CGPoint(x: x, y: pathEndY) } } return dotPoint } } fileprivate struct Dot { var percent: CGFloat var pathStartX: CGFloat var color: UIColor } fileprivate struct VerticalDot { var percent: CGFloat }

This was one of our earliest Apple Health connection experiences. At the time, the whole app including this screen was 100% UIKit. The animation is made by manually drawing every image and shape at the correct position on every frame, with the refresh driven by a CADisplayLink. I love this style of procedural UI, where layout is calculated manually – it's very similar to generative art.

Nice Confetti

import UIKit import QuartzCore public final class ConfettiView: UIView { public var colors = GoalProgressIndicator().trackGradientColors public var intensity: Float = 0.8 public var style: ConfettiViewStyle = .large private(set) var emitter: CAEmitterLayer? private var active = false private var image = UIImage(named: "confetti")?.cgImage public func startConfetti(beginAtTimeZero: Bool = true) { emitter?.removeFromSuperlayer() emitter = CAEmitterLayer() if beginAtTimeZero { emitter?.beginTime = CACurrentMediaTime() } emitter?.emitterPosition = CGPoint(x: frame.size.width / 2.0, y: -10) emitter?.emitterShape = .line emitter?.emitterSize = CGSize(width: frame.size.width, height: 1) var cells = [CAEmitterCell]() for color in colors { cells.append(confettiWithColor(color: color)) } emitter?.emitterCells = cells switch style { case .large: emitter?.birthRate = 4 DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.emitter?.birthRate = 0.6 } case .small: emitter?.birthRate = 0.35 } layer.addSublayer(emitter!) active = true } public func stopConfetti() { emitter?.birthRate = 0 active = false } public override func layoutSubviews() { super.layoutSubviews() emitter?.emitterPosition = CGPoint(x: frame.size.width / 2.0, y: -10) emitter?.emitterSize = CGSize(width: frame.size.width, height: 1) } func confettiWithColor(color: UIColor) -> CAEmitterCell { let confetti = CAEmitterCell() confetti.birthRate = 12.0 * intensity confetti.lifetime = 14.0 * intensity confetti.lifetimeRange = 0 confetti.color = color.cgColor confetti.velocity = CGFloat(350.0 * intensity) confetti.velocityRange = CGFloat(80.0 * intensity) confetti.emissionLongitude = CGFloat(Double.pi) confetti.emissionRange = CGFloat(Double.pi) confetti.spin = CGFloat(3.5 * intensity) confetti.spinRange = CGFloat(4.0 * intensity) confetti.scaleRange = CGFloat(intensity) confetti.scaleSpeed = CGFloat(-0.1 * intensity) confetti.contents = image confetti.contentsScale = 1.5 confetti.setValue("plane", forKey: "particleType") confetti.setValue(Double.pi, forKey: "orientationRange") confetti.setValue(Double.pi / 2, forKey: "orientationLongitude") confetti.setValue(Double.pi / 2, forKey: "orientationLatitude") if style == .small { confetti.contentsScale = 3.0 confetti.velocity = CGFloat(70.0 * intensity) confetti.velocityRange = CGFloat(20.0 * intensity) } return confetti } public func isActive() -> Bool { return self.active } } public enum ConfettiViewStyle { case large case small }

Confetti has been common on iOS for a while, but several people have told me that our confetti looks especially nice. We use SpriteKit, which I believe is the best looking and most performant way to do it. The key is to take advantage of all of CAEmitterCell's randomization properties. The subtle orientation transforms on each particle really sell it.

Recent Photo Picker

import UIKit import PureLayout protocol RecentPhotoPickerDelegate: AnyObject { func recentPhotoPickerPickedPhoto(_ photo: UIImage) } final class RecentPhotoPicker: UIView { // MARK: - Constants let buttonSize: CGSize = CGSize(width: 46, height: 68) let collapsedOverlap: CGFloat = 12 let expandedSpacing: CGFloat = 8 let deselectedBorderColor: UIColor = UIColor(white: 0.15, alpha: 1) let inactiveBorderColor: UIColor = UIColor(white: 0.08, alpha: 1) // MARK: - Variables weak var delegate: RecentPhotoPickerDelegate? private var activityIndicator: UIActivityIndicatorView? private var loadingSquare: UIImageView? private var buttons: [ScalingPressButton] = [] private var widthConstraint: NSLayoutConstraint? private var buttonLeadingConstraints: [NSLayoutConstraint] = [] private var state: RecentPhotoPickerState = .collapsed // MARK: - Setup override func awakeFromNib() { super.awakeFromNib() setup() } private func setup() { layer.masksToBounds = false backgroundColor = .black loadingSquare = UIImageView(image: UIImage(named: "button_editor_empty")?.withRenderingMode(.alwaysTemplate)) loadingSquare?.tintColor = inactiveBorderColor addSubview(loadingSquare!) loadingSquare?.autoPinEdge(toSuperviewEdge: .top, withInset: 18) loadingSquare?.autoAlignAxis(.vertical, toSameAxisOf: self, withOffset: 0) loadingSquare?.autoSetDimensions(to: buttonSize) activityIndicator = UIActivityIndicatorView(style: .medium) activityIndicator?.startAnimating() addSubview(activityIndicator!) activityIndicator?.autoAlignAxis(.horizontal, toSameAxisOf: loadingSquare!) activityIndicator?.autoAlignAxis(.vertical, toSameAxisOf: loadingSquare!) widthConstraint = self.autoSetDimension(.width, toSize: buttonSize.width + expandedSpacing * 2) } func addButtonsWithPhotos(_ photos: [UIImage]) { var prevButton: ScalingPressButton? for i in 0..<photos.count { let button = ScalingPressButton() button.imageView?.contentMode = .scaleAspectFill button.imageView?.layer.cornerRadius = 5.5 button.imageView?.layer.cornerCurve = .continuous button.imageView?.layer.masksToBounds = true button.imageView?.layer.minificationFilter = .trilinear button.imageView?.layer.minificationFilterBias = 0.06 button.imageEdgeInsets = UIEdgeInsets(top: 2.5, left: 2.5, bottom: 2.5, right: 2.5) let grayBorder = UIImage(named: "button_editor_empty")?.withRenderingMode(.alwaysTemplate) button.setBackgroundImage(grayBorder, for: .normal) button.tintColor = deselectedBorderColor button.setImage(photos[i], for: .normal) button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside) button.alpha = 0 button.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) addSubview(button) sendSubviewToBack(button) buttons.append(button) button.autoPinEdge(toSuperviewEdge: .top, withInset: 18) button.autoSetDimensions(to: buttonSize) if let prev = prevButton { let constraint = button.autoPinEdge(.leading, to: .leading, of: prev, withOffset: collapsedOverlap) buttonLeadingConstraints.append(constraint) } else { button.autoPinEdge(.leading, to: .leading, of: self) } prevButton = button } layoutIfNeeded() if !buttons.isEmpty { self.widthConstraint?.constant = CGFloat(buttons.count - 1) * collapsedOverlap + buttonSize.width + (expandedSpacing * 2) hideLoadingView() for (i, button) in self.buttons.enumerated() { UIView.animate(withDuration: 0.5, delay: TimeInterval(i) * 0.05, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.1, options: [.curveEaseIn], animations: { button.alpha = 1 button.transform = .identity }, completion: nil) } } else { self.widthConstraint?.constant = 0 hideLoadingView() } } private func hideLoadingView() { UIView.animate(withDuration: 0.6, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.1, options: [.curveEaseIn], animations: { self.activityIndicator?.alpha = 0 self.loadingSquare?.alpha = 0 self.superview?.layoutIfNeeded() }, completion: nil) } private func expand() { buttonLeadingConstraints.forEach { constraint in constraint.constant = buttonSize.width + expandedSpacing } widthConstraint?.constant = CGFloat(buttons.count) * (buttonSize.width + expandedSpacing) UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.1, options: [.curveEaseIn, .allowUserInteraction], animations: { self.superview?.layoutIfNeeded() }, completion: nil) state = .expanded } func deselectAllButtons() { buttons.forEach { button in button.tintColor = deselectedBorderColor } } @objc private func buttonTapped(_ button: ScalingPressButton) { if state == .collapsed && buttons.count > 1 { expand() return } if let image = button.image(for: .normal) { delegate?.recentPhotoPickerPickedPhoto(image) } for b in buttons { UIView.transition(with: button, duration: 0.2, options: [.transitionCrossDissolve], animations: { b.tintColor = (b === button) ? .white : self.deselectedBorderColor }, completion: nil) } } } enum RecentPhotoPickerState { case collapsed case expanded }

This fun photo widget is one of my favorite UIKit components in the app. UIKit makes it easy to stack, stagger, and choreograph multi-step animation sequences without sacrificing performance. We can transition from the loading state into the loaded state and simultaneously transition in all the photos views with a staggered effect.

The photos in this picker are pulled from around the timestamp of the corresponding workout, making it easy to quickly find a photo you took during your workout.

Vertical Picker

import UIKit import PureLayout class HitTestView: UIView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return outsideBoundsHitTest(point, with: event) } } class HitTestScrollView: UIScrollView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return outsideBoundsHitTest(point, with: event) } } extension UIView { func outsideBoundsHitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard isUserInteractionEnabled else { return nil } guard !isHidden else { return nil } guard alpha >= 0.01 else { return nil } for subview in subviews.reversed() { let convertedPoint = subview.convert(point, from: self) if let candidate = subview.hitTest(convertedPoint, with: event) { return candidate } } return nil } } final class VerticalPicker: HitTestView { private var label: UILabel! private var backgroundView: UIView! private var buttons: [UIButton] = [] private var buttonBottomConstraints: [NSLayoutConstraint] = [] private var selectedIdx: Int = 0 private var state: VerticalPickerState = .contracted private let generator = UIImpactFeedbackGenerator(style: .medium) private var panGR: UIPanGestureRecognizer? private let expandedWidth: CGFloat = 77.0 private let contractedWidth: CGFloat = 60.0 var tapHandler: ((_ selectedIdx: Int) -> Void)? init(title: String, buttonImages: [UIImage]) { super.init(frame: .zero) backgroundColor = .clear layer.masksToBounds = false clipsToBounds = false label = UILabel() label.text = title label.font = UIFont.systemFont(ofSize: 12.0, weight: .semibold) label.textColor = .white addSubview(label) backgroundView = UIView() backgroundView.layer.cornerRadius = 12.0 backgroundView.layer.cornerCurve = .continuous backgroundView.layer.masksToBounds = true addSubview(backgroundView) let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) backgroundView.addSubview(visualEffectView) visualEffectView.autoPinEdgesToSuperviewEdges() for (i, image) in buttonImages.enumerated() { let button = ScalingPressButton() button.setImage(image, for: .normal) button.alpha = (i == selectedIdx) ? 1.0 : 0.0 button.tag = i button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside) button.addTarget(self, action: #selector(buttonTouchDown(_:)), for: .touchDown) buttons.append(button) backgroundView.addSubview(button) button.autoPinEdge(toSuperviewEdge: .leading) button.autoPinEdge(toSuperviewEdge: .trailing) button.autoSetDimensions(to: CGSize(width: expandedWidth, height: expandedWidth)) let bottomConstraint = button.autoPinEdge(toSuperviewEdge: .bottom) buttonBottomConstraints.append(bottomConstraint) } backgroundView.autoSetDimension(.width, toSize: expandedWidth) backgroundView.autoAlignAxis(toSuperviewAxis: .vertical) backgroundView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 20.0) backgroundView.autoPinEdge(.top, to: .top, of: buttons.last!) backgroundView.transform = CGAffineTransform(scaleX: contractedWidth / expandedWidth, y: contractedWidth / expandedWidth) label.autoPinEdge(toSuperviewEdge: .top, withInset: 94.0) label.autoPinEdge(toSuperviewEdge: .bottom) label.autoAlignAxis(toSuperviewAxis: .vertical) panGR = UIPanGestureRecognizer(target: self, action: #selector(panGestureHandler(_:))) addGestureRecognizer(panGR!) } @objc private func buttonTouchDown(_ button: UIButton) { expand() } @objc private func buttonTapped(_ button: UIButton) { if (state == .expanding || state == .contracting) && button.tag == selectedIdx { return } selectedIdx = button.tag tapHandler?(button.tag) contract() generator.impactOccurred() } @objc func panGestureHandler(_ recognizer: UIPanGestureRecognizer) { if recognizer.state == .ended || recognizer.state == .cancelled || recognizer.state == .failed { contract() return } let location = recognizer.location(in: backgroundView) let closestButton = buttons.min { button1, button2 in let distance1 = location.distance(to: button1.center) let distance2 = location.distance(to: button2.center) return distance1 < distance2 } guard let closestButton = closestButton, closestButton.tag != selectedIdx else { return } selectedIdx = closestButton.tag tapHandler?(closestButton.tag) generator.impactOccurred() updateButtonSelection() } func selectIdx(_ idx: Int) { guard idx != selectedIdx else { return } selectedIdx = idx for button in buttons { button.alpha = (button.tag == selectedIdx) ? 1.0 : 0.0 } } func expand() { guard state == .contracted || state == .contracting else { return } state = .expanding for (i, constraint) in buttonBottomConstraints.enumerated() { constraint.constant = -0.8 * expandedWidth * CGFloat(i) } UIView.animate(withDuration: 0.45, delay: 0.0, usingSpringWithDamping: 0.92, initialSpringVelocity: 1.0, options: [.curveEaseIn, .allowUserInteraction, .allowAnimatedContent], animations: { self.layoutIfNeeded() self.backgroundView.transform = .identity }, completion: { _ in self.state = .expanded }) updateButtonSelection() } func updateButtonSelection() { UIView.animate(withDuration: 0.3, delay: 0, options: [.allowUserInteraction, .curveEaseOut], animations: { for button in self.buttons { button.alpha = (button.tag == self.selectedIdx) ? 1.0 : 0.5 } }, completion: nil) } func contract() { guard state == .expanded || state == .expanding else { return } state = .contracting for constraint in buttonBottomConstraints { constraint.constant = 0 } let scale = contractedWidth / expandedWidth UIView.animate(withDuration: 0.45, delay: 0.0, usingSpringWithDamping: 0.92, initialSpringVelocity: 1, options: [.curveEaseIn, .allowUserInteraction], animations: { self.layoutIfNeeded() self.backgroundView.transform = CGAffineTransform(scaleX: scale, y: scale) }, completion: { _ in self.state = .contracted }) UIView.animate(withDuration: 0.17) { for button in self.buttons { button.alpha = (button.tag == self.selectedIdx) ? 1.0 : 0.0 } } } required init?(coder: NSCoder) { super.init(coder: coder) } } private enum VerticalPickerState { case expanding case expanded case contracting case contracted }

This vertical picker is used to change the text alignment when designing a share image. The interaction design is really detailed here – you can swipe your finger up immediately after touching down on the screen and the control will engage and let you quickly swipe between options.

This is a great example of where UIKit really shines for tricky interaction design. This would be much more of a headache to perfectly replicate in SwiftUI, which doesn't feature nearly as granular touch event handling.

Share Asset Generation

import Foundation import UIKit class CanvasShareImageGenerator { static let backgroundRouteAlpha: CGFloat = 0.1 static let rescale: CGFloat = 4 static func generateShareImages(canvas: LayoutCanvas, design: ActivityDesign, cancel: @escaping (() -> Bool), progress: @escaping ((Float) -> Void), completion: @escaping ((_ images: ShareImages) -> Void)) { makeBaseImage(canvas: canvas) { (p) in progress(p * 0.5) } completion: { (baseImageInstaStory, baseImageInstaPost, baseImageTwitter) in if cancel() { return } let layoutIsFullscreen = design.cutoutShape == .fullScreen let scrollViewFrame = canvas.cutoutShapeView.photoFrame let zoomScale = CGFloat(design.photoZoom) let contentOffset = design.photoOffset var imageViewFrame = canvas.cutoutShapeView.photoFrame.insetBy(dx: -0.5 * scrollViewFrame.width * (zoomScale - 1.0), dy: -0.5 * scrollViewFrame.height * (zoomScale - 1.0)) imageViewFrame.origin.x = -1 * contentOffset.x imageViewFrame.origin.y = -1 * contentOffset.y let userImage = canvas.mediaType != .none ? canvas.cutoutShapeView.image : nil let userImageFrame = CGSize.aspectFit(aspectRatio: userImage?.size ?? .zero, inRect: imageViewFrame) let userImageFrameMultiplier = CGRect(x: userImageFrame.origin.x / canvas.cutoutShapeView.photoFrame.width, y: userImageFrame.origin.y / canvas.cutoutShapeView.photoFrame.height, width: userImageFrame.width / canvas.cutoutShapeView.photoFrame.width, height: userImageFrame.height / canvas.cutoutShapeView.photoFrame.height) progress(0.6) let opaque = (canvas.mediaType != .video) let instagramStory = makeImage(withAspectRatio: 9/16, palette: design.palette, baseImage: baseImageInstaStory, padTop: true, opaque: opaque, userImage: userImage, userImageFrameMultiplier: userImageFrameMultiplier, layoutIsFullscreen: layoutIsFullscreen) if cancel() { return } progress(0.75) let instagramFeed = makeImage(withAspectRatio: 1, palette: design.palette, baseImage: baseImageInstaPost, padTop: false, opaque: opaque, userImage: userImage, userImageFrameMultiplier: userImageFrameMultiplier, layoutIsFullscreen: layoutIsFullscreen) if cancel() { return } progress(0.9) let twitter = makeImage(withAspectRatio: 3 / 4, palette: design.palette, baseImage: baseImageTwitter, padTop: false, opaque: opaque, userImage: userImage, userImageFrameMultiplier: userImageFrameMultiplier, layoutIsFullscreen: layoutIsFullscreen) if cancel() { return } progress(1) let images = ShareImages(base: baseImageInstaStory, instagramStory: instagramStory, instagramFeed: instagramFeed, twitter: twitter) DispatchQueue.main.async { completion(images) } } } static func renderInstaStoryBaseImage(_ canvas: LayoutCanvas, include3DRoute: Bool = false, completion: @escaping ((UIImage) -> Void)) { if NSUbiquitousKeyValueStore.default.shouldShowAnyDistanceBranding { canvas.watermark.isHidden = false } if canvas.mediaType == .none { canvas.cutoutShapeView.isHidden = true } canvas.cutoutShapeView.prepareForExport(true) // Rescale the canvas UIView.scaleView(canvas.view, scaleFactor: rescale) let frame = canvas.view.frame let layer = canvas.view.layer let opaque = (canvas.mediaType != .video) func finish(finalImage: UIImage) { DispatchQueue.main.async { completion(finalImage) canvas.watermark.isHidden = true canvas.cutoutShapeView.prepareForExport(false) canvas.cutoutShapeView.addMediaButtonImage.isHidden = canvas.mediaType == .none canvas.cutoutShapeView.isHidden = false } } renderLayer(layer, frame: frame, rescale: rescale, opaque: opaque) { (baseImageInstaStory) in if include3DRoute { // Include a static image of the 3D route UIGraphicsBeginImageContextWithOptions(baseImageInstaStory.size, opaque, 1) baseImageInstaStory.draw(at: .zero) let routeFrame = canvas.route3DView.convert(canvas.route3DView.frame, to: canvas) let scaledRouteFrame = CGRect(x: routeFrame.origin.x * rescale, y: routeFrame.origin.y * rescale, width: routeFrame.width * rescale, height: routeFrame.height * rescale) let snapshot = canvas.route3DView.snapshot() snapshot.draw(in: scaledRouteFrame) let image = UIGraphicsGetImageFromCurrentImageContext()! UIGraphicsEndImageContext() finish(finalImage: image) } else { finish(finalImage: baseImageInstaStory) } } } static func renderBackgroundAndOverlay(_ canvas: LayoutCanvas, completion: @escaping ((UIImage) -> Void)) { // Rescale the canvas 4x let rescale: CGFloat = 4 UIView.scaleView(canvas.view, scaleFactor: rescale) let frame = canvas.view.frame let layer = canvas.view.layer let viewsToHide: [UIView] = [canvas.stackView, canvas.goalProgressIndicator, canvas.goalProgressYearLabel, canvas.goalProgressDistanceLabel, canvas.locationActivityTypeView] let previousViewHiddenStates: [Bool] = viewsToHide.map { $0.isHidden } viewsToHide.forEach { view in view.isHidden = true } renderLayer(layer, frame: frame, rescale: rescale, opaque: true) { image in DispatchQueue.main.async { zip(viewsToHide, previousViewHiddenStates).forEach { view, hidden in view.isHidden = hidden } completion(image) } } } static func renderStats(_ canvas: LayoutCanvas, completion: @escaping ((UIImage) -> Void)) { // Rescale the canvas 4x let rescale: CGFloat = 4.0 UIView.scaleView(canvas.view, scaleFactor: rescale) let frame = canvas.view.frame let layer = canvas.view.layer let viewsToHide: [UIView] = [canvas.cutoutShapeView, canvas.goalProgressIndicator, canvas.goalProgressYearLabel, canvas.goalProgressDistanceLabel] let previousViewHiddenStates: [Bool] = viewsToHide.map { $0.isHidden } viewsToHide.forEach { view in view.isHidden = true } renderLayer(layer, frame: frame, rescale: rescale, opaque: false) { image in DispatchQueue.main.async { completion(image) zip(viewsToHide, previousViewHiddenStates).forEach { view, hidden in view.isHidden = hidden } } } } /// Renders base images (canvas aspect ratio) for Instagram story, post, and Twitter fileprivate static func makeBaseImage(canvas: LayoutCanvas, progress: @escaping ((Float) -> Void), completion: @escaping ((_ baseImageInstaStory: UIImage, _ baseImageInstaPost: UIImage, _ baseImageTwitter: UIImage) -> Void)) { if NSUbiquitousKeyValueStore.default.shouldShowAnyDistanceBranding { canvas.watermark.isHidden = false } canvas.cutoutShapeView.prepareForExport(true) let layoutIsFullscreen = canvas.cutoutShapeView.cutoutShape == .fullScreen if canvas.mediaType == .none || layoutIsFullscreen { // Hide the user image view so we can draw it later & extend // the image to the edges for Instagram posts. canvas.cutoutShapeView.isHidden = true } if layoutIsFullscreen { canvas.tintView.isHidden = true } // Rescale the canvas 4x let rescale: CGFloat = 4.0 UIView.scaleView(canvas.view, scaleFactor: rescale) let frame = canvas.view.frame let layer = canvas.view.layer let prevWatermark = canvas.watermark.image // Render Instagram story base image renderLayer(layer, frame: frame, rescale: rescale, opaque: false) { (baseImageInstaStory) in progress(0.33) // Render Instagram post base image renderLayer(layer, frame: frame, rescale: rescale, opaque: false) { (baseImageInstaPost) in progress(0.66) DispatchQueue.main.async { // Render Twitter post base image renderLayer(layer, frame: frame, rescale: rescale, opaque: false) { (baseImageTwitter) in progress(0.99) DispatchQueue.main.async { canvas.watermark.image = prevWatermark canvas.cutoutShapeView.prepareForExport(false) canvas.cutoutShapeView.addMediaButtonImage.isHidden = canvas.mediaType == .none canvas.cutoutShapeView.isHidden = false canvas.tintView.isHidden = false canvas.watermark.isHidden = true } completion(baseImageInstaStory, baseImageInstaPost, baseImageTwitter) } } } } } internal static func renderLayer(_ layer: CALayer, frame: CGRect, rescale: CGFloat, opaque: Bool = true, completion: @escaping ((_ image: UIImage) -> Void)) { DispatchQueue.global(qos: .userInitiated).async { let bigSize = CGSize(width: frame.size.width * rescale, height: frame.size.height * rescale) UIGraphicsBeginImageContextWithOptions(bigSize, opaque, 1) let context = UIGraphicsGetCurrentContext()! context.scaleBy(x: rescale, y: rescale) layer.render(in: context) let image = UIGraphicsGetImageFromCurrentImageContext()! UIGraphicsEndImageContext() completion(image) } } fileprivate static func makeImage(withAspectRatio aspectRatio: CGFloat, palette: Palette = .dark, baseImage: UIImage, padTop: Bool = false, opaque: Bool = true, userImage: UIImage?, userImageFrameMultiplier: CGRect, layoutIsFullscreen: Bool) -> UIImage { let maxDimension = max(baseImage.size.width, baseImage.size.height) let topPadding = padTop ? ((baseImage.size.width * (1 / aspectRatio)) - baseImage.size.height) / 2 : 0 let size = CGSize(width: (maxDimension + topPadding) * aspectRatio, height: maxDimension + topPadding) UIGraphicsBeginImageContextWithOptions(size, opaque, 1) let context = UIGraphicsGetCurrentContext()! if opaque { context.setFillColor(palette.backgroundColor.cgColor) context.fill(CGRect(origin: .zero, size: size)) } if layoutIsFullscreen { if let backgroundUserImage = userImage { let xOffset: CGFloat = (size.width - baseImage.size.width) / 2 let userImageFrame: CGRect = CGRect(x: userImageFrameMultiplier.origin.x * baseImage.size.width + xOffset, y: userImageFrameMultiplier.origin.y * baseImage.size.height + topPadding, width: userImageFrameMultiplier.size.width * baseImage.size.width, height: userImageFrameMultiplier.size.height * baseImage.size.height) let aspectFilledUserImageFrame = CGSize.aspectFill(aspectRatio: CGSize(width: backgroundUserImage.size.width, height: backgroundUserImage.size.height), minimumSize: size) if aspectFilledUserImageFrame.size.width > userImageFrame.size.width && aspectFilledUserImageFrame.size.height > userImageFrame.size.height { backgroundUserImage.draw(in: aspectFilledUserImageFrame) } else { backgroundUserImage.draw(in: userImageFrame) } let topGradient = UIImage(named: "layout_top_gradient") topGradient?.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height * 0.3), blendMode: .normal, alpha: 0.4) let bottomGradient = UIImage(named: "layout_gradient") bottomGradient?.draw(in: CGRect(x: 0, y: size.height * 0.5, width: size.width, height: size.height * 0.5), blendMode: .normal, alpha: 0.5) if !palette.backgroundColor.isReallyDark { palette.backgroundColor.withAlphaComponent(0.3).setFill() context.fill(CGRect(origin: .zero, size: size)) } } } let baseImageRect = CGRect(x: (size.width / 2) - baseImage.size.width / 2, y: topPadding + (size.height / 2) - baseImage.size.height / 2, width: baseImage.size.width, height: baseImage.size.height) baseImage.draw(in: baseImageRect) let finalImage = UIGraphicsGetImageFromCurrentImageContext()! UIGraphicsEndImageContext() return finalImage } }

All of our share assets are generated with CoreGraphics, a powerful native graphics library that's been around for a very long time.

Header With Progressive Blur

import SwiftUI import UIKit public enum VariableBlurDirection { case blurredTopClearBottom case blurredBottomClearTop } public struct VariableBlurView: UIViewRepresentable { public var maxBlurRadius: CGFloat = 2 public var direction: VariableBlurDirection = .blurredTopClearBottom public var startOffset: CGFloat = 0 public func makeUIView(context: Context) -> VariableBlurUIView { VariableBlurUIView(maxBlurRadius: maxBlurRadius, direction: direction, startOffset: startOffset) } public func updateUIView(_ uiView: VariableBlurUIView, context: Context) {} } open class VariableBlurUIView: UIVisualEffectView { public init(maxBlurRadius: CGFloat = 20, direction: VariableBlurDirection = .blurredTopClearBottom, startOffset: CGFloat = 0) { super.init(effect: UIBlurEffect(style: .regular)) // Same but no need for `CAFilter.h`. let CAFilter = NSClassFromString("CAFilter")! as! NSObject.Type let variableBlur = CAFilter.self.perform(NSSelectorFromString("filterWithType:"), with: "variableBlur").takeUnretainedValue() as! NSObject // The blur radius at each pixel depends on the alpha value of the corresponding pixel in the gradient mask. // An alpha of 1 results in the max blur radius, while an alpha of 0 is completely unblurred. let gradientImage = direction == .blurredTopClearBottom ? UIImage(named: "layout_top_gradient")?.cgImage : UIImage(named: "layout_gradient")?.cgImage variableBlur.setValue(maxBlurRadius, forKey: "inputRadius") variableBlur.setValue(gradientImage, forKey: "inputMaskImage") variableBlur.setValue(true, forKey: "inputNormalizeEdges") // We use a `UIVisualEffectView` here purely to get access to its `CABackdropLayer`, // which is able to apply various, real-time CAFilters onto the views underneath. let backdropLayer = subviews.first?.layer // Replace the standard filters (i.e. `gaussianBlur`, `colorSaturate`, etc.) with only the variableBlur. backdropLayer?.filters = [variableBlur] // Get rid of the visual effect view's dimming/tint view, so we don't see a hard line. for subview in subviews.dropFirst() { subview.alpha = 0 } } override open func layoutSubviews() { subviews.first?.layer.frame = self.bounds } required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { subviews.first?.layer.filters = nil } open override func didMoveToWindow() { // fixes visible pixelization at unblurred edge (https://github.com/nikstar/VariableBlur/issues/1) guard let window, let backdropLayer = subviews.first?.layer else { return } backdropLayer.setValue(window.screen.scale, forKey: "scale") } open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {} } fileprivate struct TopGradient: View { @Binding var scrollViewOffset: CGFloat var body: some View { VStack(spacing: 0) { Color.black .frame(height: 80.0) Image("gradient_bottom_ease_in_out") .resizable(resizingMode: .stretch) .scaleEffect(y: -1) .frame(width: UIScreen.main.bounds.width, height: 60.0) .overlay { VariableBlurView() .offset(y: -10.0) } Spacer() } .opacity((1.0 - (scrollViewOffset / 50.0)).clamped(to: 0...1)) .ignoresSafeArea() } } fileprivate struct TitleView: View { @Binding var scrollViewOffset: CGFloat var body: some View { VStack(alignment: .leading) { Spacer() .frame(height: 45.0) let p = (scrollViewOffset / -80.0) HStack { Text("Any Distance") .font(Font(UIFont.systemFont(ofSize: 23.0, weight: .bold, width: .expanded))) .foregroundStyle(Color(white: 0.6)) .scaleEffect((0.6 + ((1.0 - p) * 0.4)).clamped(to: 0.6...1.0), anchor: .leading) .offset(y: scrollViewOffset < 0 ? 0 : (0.3 * scrollViewOffset)) .offset(y: (-22.0 * p).clamped(to: -22.0...0.0)) Spacer() } .overlay { HStack { Spacer() Button { // } label: { Image(systemName: "gear") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 24.0, height: 24.0) .foregroundStyle(Color.white) .padding() .contentShape(Rectangle()) .opacity((1.0 - (scrollViewOffset / -70)).clamped(to: 0...1) * 0.6) .blur(radius: (10 * scrollViewOffset / -70).clamped(to: 0...10)) } .offset(x: 16.0) .offset(y: scrollViewOffset < 0 ? 0 : (0.3 * scrollViewOffset)) } } } .padding(.top, -22.5) .padding([.leading, .trailing], 20.0) } } struct HeaderView: View { @Binding var scrollViewOffset: CGFloat var body: some View { ZStack { TopGradient(scrollViewOffset: $scrollViewOffset) VStack { TitleView(scrollViewOffset: $scrollViewOffset) Spacer() } } } } struct ReadableScrollView<Content: View>: View { struct CGFloatPreferenceKey: PreferenceKey { static var defaultValue: CGFloat { 0.0 } static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} } struct CGSizePreferenceKey: PreferenceKey { static var defaultValue: CGSize { .zero } static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} } @Binding var offset: CGFloat @Binding var contentSize: CGSize var presentedInSheet: Bool = false var showsIndicators: Bool = true var content: Content init(offset: Binding<CGFloat>, contentSize: Binding<CGSize>? = nil, presentedInSheet: Bool = false, showsIndicators: Bool = true, @ViewBuilder contentBuilder: () -> Content) { self._offset = offset self._contentSize = contentSize ?? .constant(.zero) self.presentedInSheet = presentedInSheet self.showsIndicators = showsIndicators self.content = contentBuilder() } var scrollReader: some View { GeometryReader { geometry in Color.clear .preference(key: CGFloatPreferenceKey.self, value: geometry.frame(in: .named("scroll")).minY) .preference(key: CGSizePreferenceKey.self, value: geometry.size) } } var body: some View { ScrollView(showsIndicators: showsIndicators) { VStack { content } .background(scrollReader.ignoresSafeArea()) .onPreferenceChange(CGFloatPreferenceKey.self) { value in self.offset = value - (presentedInSheet ? 10.0 : 0.0) } .onPreferenceChange(CGSizePreferenceKey.self) { value in self.contentSize = value } } } } struct HeaderDemoView: View { @State var scrollViewOffset: CGFloat = 0.0 var body: some View { ZStack { ReadableScrollView(offset: $scrollViewOffset) { VStack { ForEach(1..<20) { _ in RoundedRectangle(cornerRadius: 10.0) .foregroundStyle(Color(white: 0.8)) .frame(height: 60.0) } } .padding(.top, 100.0) .padding(.horizontal, 20.0) } HeaderView(scrollViewOffset: $scrollViewOffset) } .background(Color.black) } } #Preview { HeaderDemoView() }

We use this style of navigation header throughout the app. It uses the current scroll position to apply transforms to the title view, making it shrink as you scroll down. Underneath, there's a subtle progressive blur layered with a cubic-eased gradient to make the content underneath fade out smoothly.

Custom Refresh Control

import SwiftUI struct RefreshableScrollView<Content: View>: View { @Binding var offset: CGFloat @Binding var isRefreshing: Bool var presentedInSheet: Bool = false var content: Content @State private var refreshControlVisible: Bool = false private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium) private let refreshOffset: CGFloat = 150.0 init(offset: Binding<CGFloat>, isRefreshing: Binding<Bool>, presentedInSheet: Bool = false, @ViewBuilder contentBuilder: () -> Content) { self._offset = offset self._isRefreshing = isRefreshing self.presentedInSheet = presentedInSheet self.content = contentBuilder() } var scrollReader: some View { GeometryReader { geometry in Color.clear .preference(key: CGFloatPreferenceKey.self, value: geometry.frame(in: .named("scroll")).minY) } } var body: some View { ScrollView(showsIndicators: false) { LazyVStack { content } .if(!presentedInSheet) { view in view .overlay { VStack { ZStack { ProgressView() .opacity(isRefreshing ? 1.0 : 0.0) .offset(y: -12.0) Text("PULL TO REFRESH") .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.white) .opacity((offset / refreshOffset).clamped(to: 0...1) * 0.6) .opacity(isRefreshing ? 0.0 : 1.0) .offset(y: -6.0) } .offset(y: -0.9 * offset) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isRefreshing) Spacer() } } } .offset(y: isRefreshing ? 30.0 : 0.0) .animation(.spring(response: 0.5, dampingFraction: 0.6), value: isRefreshing) .background(scrollReader.ignoresSafeArea()) .onPreferenceChange(CGFloatPreferenceKey.self) { value in guard let window = UIApplication.shared.windows.first else { self.offset = value return } self.offset = value - window.safeAreaInsets.top - (presentedInSheet ? 10.0 : 0.0) if offset >= refreshOffset && !isRefreshing && !presentedInSheet { isRefreshing = true feedbackGenerator.impactOccurred() } } } .introspectScrollView { scrollView in scrollView.delaysContentTouches = false } } }

I wrote this fun custom refresh control in SwiftUI. It's baked into a reusable scroll view wrapper called RefreshableScrollView.

Onboarding Carousel (2022)

import SwiftUI fileprivate struct GradientAnimation: View { @Binding var animate: Bool @Binding var pageIdx: Int private var firstPageColors: [Color] { return [Color(hexadecimal: "#F98425"), Color(hexadecimal: "#E82840"), Color(hexadecimal: "#4A0D21"), Color(hexadecimal: "#B12040"), Color(hexadecimal: "#F4523B")] } private var secondPageColors: [Color] { return [Color(hexadecimal: "#B7F6FE"), Color(hexadecimal: "#32A0FB"), Color(hexadecimal: "#034EE7"), Color(hexadecimal: "#0131A1"), Color(hexadecimal: "#030C2F")] } private var thirdPageColors: [Color] { return [Color(hexadecimal: "#66E7FF"), Color(hexadecimal: "#04CFD5"), Color(hexadecimal: "#00A077"), Color(hexadecimal: "#00Af8B"), Color(hexadecimal: "#02251B")] } private func rand18(_ idx: Int) -> [Float] { let idxf = Float(idx) return [sin(idxf * 6.3), cos(idxf * 1.3 + 48), sin(idxf + 31.2), cos(idxf * 44.1), sin(idxf * 3333.2), cos(idxf + 1.12 * pow(idxf, 3)), sin(idxf * 22), cos(idxf * 34)] } func gradient(withColors colors: [Color], seed: Int = 0) -> some View { return ZStack { let maxXOffset = Float(UIScreen.main.bounds.width) / 2 let maxYOffset = Float(UIScreen.main.bounds.height) / 2 ForEach(Array(0...9), id: \.self) { idx in let rands = rand18(idx + seed) let fill = colors[idx % colors.count] Ellipse() .fill(fill) .frame(width: CGFloat(rands[1] + 2) * 250, height: CGFloat(rands[2] + 2) * 250) .blur(radius: 45 * 1 + CGFloat(rands[1] + rands[2]) / 2) .opacity(1) .offset(x: CGFloat(animate ? rands[3] * maxXOffset : rands[4] * maxXOffset), y: CGFloat(animate ? rands[5] * maxYOffset : rands[6] * maxYOffset)) .animation(.easeInOut(duration: TimeInterval(rands[7] + 3) * 2.5).repeatForever(autoreverses: true), value: animate) } } .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height+100) .drawingGroup() } var body: some View { ZStack { gradient(withColors: firstPageColors, seed: 5) .opacity(pageIdx == 0 ? 1 : 0) .animation(.easeInOut(duration: 1.5), value: pageIdx) gradient(withColors: secondPageColors, seed: 5) .opacity(pageIdx == 1 ? 1 : 0) .animation(.easeInOut(duration: 1.5), value: pageIdx) gradient(withColors: thirdPageColors, seed: 6) .opacity(pageIdx == 2 ? 1 : 0) .animation(.easeInOut(duration: 1.5), value: pageIdx) Image("noise") .resizable(resizingMode: .tile) .scaleEffect(0.25) .ignoresSafeArea() .luminanceToAlpha() .frame(width: UIScreen.main.bounds.width * 4, height: UIScreen.main.bounds.height * 5) .opacity(0.15) } .onAppear { animate = true } } } fileprivate struct InfiniteScroller<Content: View>: View { var contentWidth: CGFloat var reversed: Bool = true var content: (() -> Content) @State var xOffset: CGFloat = 0 var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 0) { content() content() } .offset(x: xOffset, y: 0) } .disabled(true) .onAppear { if reversed { xOffset = -1 * contentWidth } withAnimation(.linear(duration: 20).repeatForever(autoreverses: false)) { if reversed { xOffset = 0 } else { xOffset = -contentWidth } } } } } fileprivate struct AppIcons: View { let iconWidth: CGFloat = 61 let iconSpacing: CGFloat = 16 var firstRow: some View { HStack(spacing: iconSpacing) { Image("icon_fitness") .frame(width: iconWidth, height: iconWidth) Image("icon_strava") .frame(width: iconWidth, height: iconWidth) Image("icon_garmin") .frame(width: iconWidth, height: iconWidth) Image("icon_nrc") .frame(width: iconWidth, height: iconWidth) Image("icon_peloton") .frame(width: iconWidth, height: iconWidth) Image("icon_runkeeper") .frame(width: iconWidth, height: iconWidth) Image("icon_wahoo") .frame(width: iconWidth, height: iconWidth) Image("icon_fitbod") .frame(width: iconWidth, height: iconWidth) Image("icon_ot") .frame(width: iconWidth, height: iconWidth) Rectangle() .frame(width: 0) } } var secondRow: some View { HStack(spacing: iconSpacing) { Image("icon_strava") .frame(width: iconWidth, height: iconWidth) Image("icon_fitbod") .frame(width: iconWidth, height: iconWidth) Image("icon_garmin") .frame(width: iconWidth, height: iconWidth) Image("icon_runkeeper") .frame(width: iconWidth, height: iconWidth) Image("icon_nrc") .frame(width: iconWidth, height: iconWidth) Image("icon_future") .frame(width: iconWidth, height: iconWidth) Image("icon_fitness") .frame(width: iconWidth, height: iconWidth) Image("icon_peloton") .frame(width: iconWidth, height: iconWidth) Image("icon_wahoo") .frame(width: iconWidth, height: iconWidth) Rectangle() .frame(width: 0) } } var thirdRow: some View { HStack(spacing: iconSpacing) { Image("icon_future") .frame(width: iconWidth, height: iconWidth) Image("icon_peloton") .frame(width: iconWidth, height: iconWidth) Image("icon_fitbod") .frame(width: iconWidth, height: iconWidth) Image("icon_garmin") .frame(width: iconWidth, height: iconWidth) Image("icon_nrc") .frame(width: iconWidth, height: iconWidth) Image("icon_runkeeper") .frame(width: iconWidth, height: iconWidth) Image("icon_ot") .frame(width: iconWidth, height: iconWidth) Image("icon_fitness") .frame(width: iconWidth, height: iconWidth) Image("icon_strava") .frame(width: iconWidth, height: iconWidth) Rectangle() .frame(width: 0) } } var body: some View { VStack { InfiniteScroller(contentWidth: iconWidth * 10 + iconSpacing * 10, reversed: true) { firstRow } .frame(height: iconWidth) InfiniteScroller(contentWidth: iconWidth * 10 + iconSpacing * 10, reversed: false) { secondRow } .frame(height: iconWidth) InfiniteScroller(contentWidth: iconWidth * 10 + iconSpacing * 10, reversed: true) { thirdRow } .frame(height: iconWidth) InfiniteScroller(contentWidth: iconWidth * 10 + iconSpacing * 10, reversed: false) { firstRow } .frame(height: iconWidth) } .mask { LinearGradient(colors: [.clear, .black, .black, .black, .black, .black, .clear], startPoint: .leading, endPoint: .trailing) } } } fileprivate var screenshotSize: CGSize { let aspect: CGFloat = 2.052 let verticalSafeArea = (UIApplication.shared.topWindow?.safeAreaInsets.top ?? 0) + (UIApplication.shared.topWindow?.safeAreaInsets.bottom ?? 0) let height = (UIScreen.main.bounds.height - verticalSafeArea) * 0.435 return CGSize(width: height / aspect, height: height) } fileprivate struct Carousel: View { @Binding var pageIdx: Int @Binding var dragging: Bool @State var offset: CGFloat = 0 @State private var startOffset: CGFloat? fileprivate struct CarouselItem: View { var body: some View { RoundedRectangle(cornerRadius: 16, style: .continuous) .frame(width: screenshotSize.width, height: screenshotSize.height) .foregroundColor(Color.black.opacity(0.5)) } } var item: some View { GeometryReader { geo in CarouselItem() .scaleEffect((1 - ((geo.frame(in: .global).minX) / UIScreen.main.bounds.width * 0.3)).clamped(to: 0...1)) .offset(x: pow(geo.frame(in: .global).minX / UIScreen.main.bounds.width, 2) * -60) } .frame(width: screenshotSize.width, height: screenshotSize.height) } func item(forScreen screen: Int) -> some View { GeometryReader { geo in ZStack { switch screen { case 1: Image("onboarding-screen-1") .resizable() .aspectRatio(contentMode: .fill) case 2: LoopingVideoView(videoUrl: Bundle.main.url(forResource: "onboarding-screen-2", withExtension: "mp4")!, videoGravity: .resizeAspectFill) EmptyView() case 3: Image("onboarding-screen-3") .resizable() .aspectRatio(contentMode: .fill) case 4: LoopingVideoView(videoUrl: Bundle.main.url(forResource: "onboarding-screen-4", withExtension: "mp4")!, videoGravity: .resizeAspectFill) EmptyView() case 5: Image("onboarding-screen-5") .resizable() .aspectRatio(contentMode: .fill) case 6: Image("onboarding-screen-6") .resizable() .aspectRatio(contentMode: .fill) default: EmptyView() } } .frame(width: screenshotSize.width, height: screenshotSize.height) .cornerRadius(12, style: .continuous) .scaleEffect((1 - ((geo.frame(in: .global).minX) / UIScreen.main.bounds.width * 0.3)).clamped(to: 0...1)) .offset(x: pow(geo.frame(in: .global).minX / UIScreen.main.bounds.width, 2) * -60) } .frame(width: screenshotSize.width, height: screenshotSize.height) } var contentWidth: CGFloat { return (screenshotSize.width * 6) + UIScreen.main.bounds.width + (16 * 7) } var secondPageOffset: CGFloat { return (screenshotSize.width * 3) + (16 * 3) } var thirdPageOffset: CGFloat { return (screenshotSize.width * 6) + (16 * 6) } var body: some View { HStack(spacing: 16) { item(forScreen: 1) item(forScreen: 2) item(forScreen: 3) item(forScreen: 4) item(forScreen: 5) item(forScreen: 6) AppIcons() .frame(width: UIScreen.main.bounds.width) } .id(0) .frame(height: screenshotSize.height) .offset(x: (contentWidth / 2) - (UIScreen.main.bounds.width / 2) - offset) .onChange(of: pageIdx) { newValue in print(newValue) withAnimation(smoothCurveAnimation) { switch newValue { case 0: offset = 0 case 1: offset = secondPageOffset default: offset = thirdPageOffset } } } .gesture( DragGesture() .onChanged { gesture in if startOffset == nil { startOffset = offset } let gestureOffset = gesture.location.x - gesture.startLocation.x offset = startOffset! - gestureOffset dragging = true } .onEnded { gesture in let finalOffset = startOffset! - (gesture.predictedEndLocation.x - gesture.startLocation.x) let closestOffset = [0, secondPageOffset, thirdPageOffset].min(by: { abs($0 - finalOffset) < abs($1 - finalOffset) })! let closestPage = [0, secondPageOffset, thirdPageOffset].firstIndex(of: closestOffset)!.clamped(to: (pageIdx-1)...(pageIdx+1)) let clampedClosestOffset = [0, secondPageOffset, thirdPageOffset][closestPage] pageIdx = closestPage withAnimation(.interactiveSpring(response: 0.6)) { offset = clampedClosestOffset } startOffset = nil dragging = false } ) } } struct Onboarding2022View: View { @State private var animate: Bool = false @State private var pageIdx: Int = 0 @State private var pageTimer: Timer? @State private var dragging: Bool = false private func setupPageTimer() { pageTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { _ in pageIdx = (pageIdx + 1) % 3 } } private func signInAction() {} private func getStartedAction() { let syncVC = UIStoryboard(name: "Onboarding", bundle: nil).instantiateViewController(withIdentifier: "sync") UIApplication.shared.topViewController?.present(syncVC, animated: true) } private func headlineText(forPageIdx pageIdx: Int) -> AttributedString { let string = ["**Privacy-driven**\nActivity Tracking and\n**safety-first** sharing.", "Track your goals &\nearn **motivational\nCollectibles.**", "**Easily Connect** the\nactive life apps\n**you already use.**"][pageIdx % 3] return try! AttributedString(markdown: string, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) } private func subtitleText(forPageIdx pageIdx: Int) -> String { return ["No leaderboards. No comparison.\nAny Distance Counts.", "Celebrate your active lifestyle.", "Connect with Garmin, Wahoo,\nand Apple Health."][pageIdx % 3] } struct BlurModifier: ViewModifier { var radius: CGFloat func body(content: Content) -> some View { content.blur(radius: radius) } } var body: some View { ZStack { GradientAnimation(animate: $animate, pageIdx: $pageIdx) .frame(width: 20, height: 20) VStack(alignment: .leading) { HStack { Image("wordmark") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 130) Spacer() Button(action: signInAction) { Text("Sign In") .font(.system(size: 17, weight: .medium)) .foregroundColor(.white) } } Spacer() HStack { if #available(iOS 16.0, *) { Text(headlineText(forPageIdx: pageIdx)) .font(.system(size: UIScreen.main.bounds.height * 0.041, weight: .regular)) .lineSpacing(0) .tracking(-1) .padding(.bottom, 1) } else { Text(headlineText(forPageIdx: pageIdx)) .font(.system(size: UIScreen.main.bounds.height * 0.041, weight: .semibold)) .lineSpacing(0) } } .transition(.modifier(active: BlurModifier(radius: 8), identity: BlurModifier(radius: 0)) .combined(with: .opacity) .combined(with: .scale(scale: 0.9)) .animation(smoothCurveAnimation)) .id(pageIdx) Text(subtitleText(forPageIdx: pageIdx)) .font(.system(size: (UIScreen.main.bounds.height * 0.02).clamped(to: 0...14), weight: .semibold, design: .monospaced)) .foregroundColor(.white) .opacity(0.7) .transition(.modifier(active: BlurModifier(radius: 8), identity: BlurModifier(radius: 0)) .combined(with: .opacity) .combined(with: .scale(scale: 0.9)) .animation(smoothCurveAnimation)) .id(pageIdx) Carousel(pageIdx: $pageIdx, dragging: $dragging) .frame(width: UIScreen.main.bounds.width - 40) .padding([.top, .bottom], 16) Button(action: getStartedAction) { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.white) Text("Get Started") .font(.system(size: 17, weight: .semibold)) .foregroundColor(.black) } .frame(height: 55) } Text("No sign-up required unless you want to\nstore your goals and Collectibles.") .font(.system(size: 14, weight: .regular)) .multilineTextAlignment(.center) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 50) .opacity(0.7) } .padding([.leading, .trailing], 20) } .onAppear { animate = true setupPageTimer() } .onChange(of: dragging) { newValue in if newValue == true { pageTimer?.invalidate() } else { setupPageTimer() } } } } struct Onboarding2022View_Previews: PreviewProvider { static var previews: some View { Onboarding2022View() } }

This splash page from 2022 was my favorite one.

The gradient effect in the background was done in SwiftUI using a similar technique to Access Code Field. It was eventually rewritten in Metal to be more performant.

Getting the scroll behavior right took a lot of trial and error. The current scroll position is used to apply various transforms to the container views. The 3 sections auto-scroll, but you can also scroll between them manually.

I'm really pleased with how the text transition came out. It's a custom SwiftUI ViewModifier that was meant to mimic the text transitions in Dynamic Island. We ended up using this text transition effect extensively throughout the rest of the app.

Scrubbable Line Graph

import SwiftUI import SwiftUIX fileprivate struct ProgressLine: Shape { var data: [Float] var dataMaxValue: Float var totalCount: Int func path(in rect: CGRect) -> Path { guard !data.isEmpty else { return Path() } let max = dataMaxValue * 1.1 var path = Path() let points = data.enumerated().map { (i, datum) in let x = (rect.width * CGFloat(i) / CGFloat(totalCount-1)).clamped(to: 4.0...(rect.width-4.0)) let y = (rect.height - (rect.height * CGFloat(datum / max))).clamped(to: 4.0...(rect.height-4.0)) return CGPoint(x: x, y: y) } path.move(to: points[0]) var p1 = points[0] var p2 = CGPoint.zero for i in 0..<(points.count-1) { p2 = points[i+1] let midPoint = CGPoint(x: (p1.x + p2.x)/2.0, y: (p1.y + p2.y)/2.0) path.addQuadCurve(to: midPoint, control: p1) p1 = p2 } path.addLine(to: points.last!) return path } } struct ProgressLineGraph: View { var data: [Float] var dataMaxValue: Float var fullDataCount: Int var strokeStyle: StrokeStyle var color: Color var showVerticalLine: Bool var showDot: Bool var animateProgress: Bool @Binding var dataSwipeIdx: Int @State private var lineAnimationProgress: Float = 1.0 @State private var dotOpacity: Double = 0.0 var body: some View { ZStack { color .maxWidth(.infinity) .mask { ProgressLine(data: data, dataMaxValue: dataMaxValue, totalCount: fullDataCount) .stroke(style: strokeStyle) } .mask { GeometryReader { geo in HStack(spacing: 0.0) { let width = (geo.size.width * (CGFloat(data.count-1) / CGFloat(fullDataCount-1)) * CGFloat(lineAnimationProgress)).clamped(to: 4.0...(geo.size.width-4.0)) Rectangle() .frame(width: width) Spacer() .frame(minWidth: 0.0) } } } .if(showVerticalLine && data.count > 1) { view in view .background { GeometryReader { geo in HStack(spacing: 0.0) { let width: CGFloat = { if dataSwipeIdx != -1 { return (geo.size.width * (CGFloat(dataSwipeIdx) / CGFloat(fullDataCount-1))).clamped(to: 4.0...(geo.size.width-4.0)) } else { return (geo.size.width * (CGFloat(data.count-1) / CGFloat(fullDataCount-1)) * CGFloat(lineAnimationProgress)).clamped(to: 4.0...(geo.size.width-4.0)) } }() Spacer() .frame(width: width) ZStack { Color.black Rectangle() .foregroundColor(color) .opacity(0.4) } .frame(width: 2.0) .cornerRadius(2.0) Spacer() .frame(minWidth: 0.0) } .offset(x: -1.5) .animation(dataSwipeIdx == -1 ? .timingCurve(0.42, 0.27, 0.34, 0.96, duration: 0.2) : .none, value: dataSwipeIdx) } } } .if(showDot) { view in view .overlay { GeometryReader { geo in let x = (geo.size.width * CGFloat(data.count-1) / CGFloat(fullDataCount-1)).clamped(to: 4.0...(geo.size.width-4.0)) let y = (geo.size.height - (geo.size.height * CGFloat((data.last ?? 0.0) / (dataMaxValue * 1.1)))).clamped(to: 4.0...(geo.size.height-4.0)) ZStack { TimelineView(.animation) { timeline in Canvas { context, size in let duration: CGFloat = 1.4 let time = timeline.date.timeIntervalSince1970.truncatingRemainder(dividingBy: duration) / duration let diameter = 12.0 + (20.0 * time) let rect = CGRect(x: 21.0 - (diameter / 2), y: 21.0 - (diameter / 2), width: diameter, height: diameter) let shape = Circle().path(in: rect) let color = color.opacity(1.0 - time) context.fill(shape, with: .color(color)) } } .frame(width: 42.0, height: 42.0) Circle() .fill(color) .width(12.0) .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 0) } .scaleEffect(x: dotOpacity, y: dotOpacity) .position(x: x, y: y) .opacity(dotOpacity) } } } .opacity(Double(lineAnimationProgress)) .onAppear { if !animateProgress { lineAnimationProgress = 1.0 dotOpacity = 1.0 return } lineAnimationProgress = 0.0 let duration = (0.3 + ((CGFloat(data.count-1) / CGFloat(fullDataCount-1)) * 0.9)).clamped(to: 0.0...0.9) withAnimation(.timingCurve(0.42, 0.27, 0.34, 0.96, duration: duration)) { lineAnimationProgress = 1.0 } DispatchQueue.main.asyncAfter(deadline: .now() + duration - 0.13) { withAnimation(.easeInOut(duration: 0.5)) { self.dotOpacity = 1.0 } } } } } } struct ProgressLineGraphXLabels: View { var labelStrings: [(idx: Int, string: String)] = [] var fullDataCount: Int var lrPadding: CGFloat var body: some View { Color.clear .overlay { GeometryReader { geo in ZStack(alignment: .leading) { ForEach(labelStrings, id: \.idx) { (idx, string) in let xPos: CGFloat = (lrPadding + ((geo.size.width - (lrPadding * 2.0)) * CGFloat(idx) / CGFloat(fullDataCount - 1))).clamped(to: (lrPadding+4.0)...(geo.size.width-lrPadding-4.0)) HStack { Text(string) .font(.system(size: 12.0, weight: .medium, design: .monospaced)) .foregroundColor(.white) .multilineTextAlignment(.center) .opacity(0.6) .frame(width: 50.0) .offset(x: xPos - 25.0) Spacer() } } } } } } } struct ProgressLineGraphSwipeOverlay: View { var field: PartialKeyPath<Activity> var data: [Float] var prevPeriodData: [Float] var dataFormat: (Float) -> String var startDate: Date var endDate: Date var prevPeriodStartDate: Date var prevPeriodEndDate: Date var alternatePrevPeriodLabel: String? @Binding var dataSwipeIdx: Int @Binding var showingOverlay: Bool @State private var dragLocation: CGPoint = .zero @State private var touchingDown: Bool = false @State private var longPressTimer: Timer? private let longPressDuration: TimeInterval = 0.1 private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) var percentChangeLabel: some View { ZStack { let percent = ((data[dataSwipeIdx.clamped(to: 0...(data.count-1))] / (prevPeriodData[dataSwipeIdx.clamped(to: 0...(prevPeriodData.count-1))]) .clamped(to: 0.01...Float.greatestFiniteMagnitude)) - 1.0) .clamped(to: -10.0...100.0) let glyphName: SFSymbolName = { switch abs(percent) { case 0.0: return .minusCircleFill default: return percent > 0.0 ? .arrowUpRightCircleFill : .arrowDownRightCircleFill } }() let percentString: String = { switch abs(percent) { case 100.0: return "∞" case 0.0: return "0" default: return String(Int((abs(percent) * 100).rounded())) } }() let color = ActivityProgressGraphModel.color(for: percent, field: field) HStack(spacing: 3.0) { Image(systemName: glyphName) .font(.system(size: 12.0, weight: .medium)) HStack(spacing: 0.0) { if percentString == "∞" { Text(percentString) .font(.system(size: 12.0, weight: .medium)) } else { Text(percentString) .font(.system(size: 12.0, weight: .medium, design: .monospaced)) } Text("%") .font(.system(size: 12.0, weight: .medium, design: .monospaced)) } } .foregroundColor(color) } } var body: some View { Color.clear .overlay { GeometryReader { geo in VStack { Spacer() if showingOverlay { let overlayWidth: CGFloat = dataSwipeIdx >= data.count ? 110.0 : 155.0 let xOffset = (CGFloat(dataSwipeIdx) * (geo.size.width / CGFloat(max(data.count, prevPeriodData.count) - 1)) - (overlayWidth / 2.0)) .clamped(to: 0...(geo.size.width - overlayWidth)) HStack(spacing: 0.0) { VStack(alignment: .leading, spacing: 8.0) { if dataSwipeIdx < data.count { VStack(alignment: .leading, spacing: 1.0) { HStack { Text(dataFormat(data[dataSwipeIdx.clamped(to: 0...(data.count-1))])) .font(.system(size: 13.0, weight: .medium, design: .monospaced)) .lineLimit(1) .minimumScaleFactor(0.5) Spacer() percentChangeLabel } Text(Calendar.current.date(byAdding: .day, value: dataSwipeIdx, to: startDate)!.formatted(withFormat: "MMM d YYYY")) .font(.system(size: 10.0, design: .monospaced)) .opacity(0.5) } } VStack(alignment: .leading, spacing: 1.0) { let idx = dataSwipeIdx.clamped(to: 0...(prevPeriodData.count-1)) Text(dataFormat(prevPeriodData[idx])) .font(.system(size: 13.0, weight: .medium, design: .monospaced)) .lineLimit(1) .minimumScaleFactor(0.5) if let alternatePrevPeriodLabel = alternatePrevPeriodLabel { Text(alternatePrevPeriodLabel) .font(.system(size: 10.0, design: .monospaced)) .opacity(0.5) } else { Text(Calendar.current.date(byAdding: .day, value: idx, to: prevPeriodStartDate)!.formatted(withFormat: "MMM d YYYY")) .font(.system(size: 10.0, design: .monospaced)) .opacity(0.5) } } } if dataSwipeIdx >= data.count { Spacer() } } .padding(8.0) .frame(width: overlayWidth) .background { DarkBlurView() .cornerRadius(11.0) .brightness(0.1) } .modifier(BlurOpacityTransition(speed: 2.5, anchor: UnitPoint(x: (xOffset + (overlayWidth / 2.0)) / overlayWidth, y: -2.5))) .offset(x: xOffset, y: (-1.0 * geo.size.height) - 8.0) } } } } .overlay { TouchEventView { location, view in guard let location = location else { return } touchingDown = true longPressTimer?.invalidate() longPressTimer = Timer.scheduledTimer(withTimeInterval: longPressDuration, repeats: false) { _ in guard touchingDown else { return } longPressTimer?.invalidate() longPressTimer = nil view.findContainingScrollView()?.isScrollEnabled = false dataSwipeIdx = Int(location.x / (view.bounds.width / CGFloat(max(data.count, prevPeriodData.count)))) .clamped(to: 0...max(prevPeriodData.count-1, data.count-1)) showingOverlay = true dragLocation = location } } touchMoved: { location, view in guard let location = location, showingOverlay else { return } let prevIdx = dataSwipeIdx dataSwipeIdx = Int(location.x / (view.bounds.width / CGFloat(prevPeriodData.count))) .clamped(to: 0...max(prevPeriodData.count-1, data.count-1)) if dataSwipeIdx != prevIdx { feedbackGenerator.impactOccurred() } dragLocation = location } touchCancelled: { location, view in showingOverlay = false view.findContainingScrollView()?.isScrollEnabled = true touchingDown = false dataSwipeIdx = -1 } touchEnded: { location, view in showingOverlay = false view.findContainingScrollView()?.isScrollEnabled = true touchingDown = false dataSwipeIdx = -1 } } } }

We built this screen before the release of SwiftUI graphs, so it's all custom. The graph supports scrubbing so you can dig into every data point. There are also some fun animations when the data changes.

Made With Soul In Atlanta

import UIKit final class FlickeringImageView: UIImageView { // MARK: - Variables private var isLowered: Bool = false private var flickerCount: Int = 0 // MARK: - Constants private let NUM_FLICKERS: Int = 8 // MARK: - Setup override func awakeFromNib() { super.awakeFromNib() prepareForAnimation() } override func didMoveToSuperview() { super.didMoveToSuperview() startAnimation() } func prepareForAnimation() { alpha = 0.0 } func startAnimation() { self.startGlowing() self.continueFlickering() } func startFloating() { let yTranslation: CGFloat = isLowered ? -7 : 7 isLowered = !isLowered UIView.animate(withDuration: 2.0, delay: 0.0, options: [.curveEaseInOut, .beginFromCurrentState], animations: { self.transform = CGAffineTransform(translationX: 0.0, y: yTranslation) }, completion: { [weak self] (finished) in if finished { self?.startFloating() } }) } func flicker() { let newAlpha: CGFloat = (alpha < 1.0) ? 1.0 : 0.2 alpha = newAlpha flickerCount += 1 if alpha == 1.0 && flickerCount >= NUM_FLICKERS { continueFlickering() DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.startGlowing() } } else { let delay = TimeInterval.random(in: 0.05...0.07) - (0.03 * TimeInterval(flickerCount) / TimeInterval(NUM_FLICKERS)) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in self?.flicker() } } } func continueFlickering() { let newAlpha: CGFloat = (alpha < 1.0) ? 1.0 : 0.2 alpha = newAlpha var delay: TimeInterval { if alpha < 1.0 { return TimeInterval.random(in: 0.01...0.03) } return TimeInterval.random(in: 0.03...0.4) } DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in self?.continueFlickering() } } func startGlowing(delay: TimeInterval = 0.0) { let duration = TimeInterval.random(in: 0.4...0.8) let newAlpha: CGFloat = (alpha < 1.0) ? 1.0 : 0.8 UIView.animate(withDuration: duration, delay: delay, options: [.curveEaseInOut, .beginFromCurrentState], animations: { self.alpha = newAlpha }) { [weak self] (finished) in if finished { self?.startGlowing() } } } }

I made this flickering image component to mimic the look of neon. The graphic is a copy of a real neon sign that hung outside Switchyards in downtown Atlanta, where we had an office.

Thank you from Spotted in Prod

SIP: It's an honor that our site gets to become home to this piece of iOS history. We've always considered Any Distance a blend of software and art, and there is a reason we highlight it so often. A huge thank you to Dan for capturing and sharing these snippets in a way that only he could.

Read Entire Article