Any Distance Goes Open Source

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.