Lab 052 – A 3D text countdown timer
Originally created for Looming Deadlines.
When I was working on Looming Deadlines last summer, I tried creating a 3D version of the countdown timer. visionOS 2 added some neat features to creating 3D text. I learned about this from the WWDC Session called Build a spatial drawing app with RealityKit.
I didn’t end up using this in my project, but I wanted to share it here in case someone else finds it helpful. One major issue I was never able to solve was the slight movement when the seconds tick. Even using a monospaced font as the basis of the extraction, we can still see things jump around a bit. I think this has something to do with the total bounds of the 3D mesh changing.
Lab Code
The code for this is a bit of a mess. I pulled this together from several files in Looming Deadlines and ripped out all everything I could.
import SwiftUI
import RealityKit
import RealityKitContent
import Combine
struct Lab052: View {
@State var someDate = Date().addingTimeInterval(36000)
var body: some View {
CountdownWrapper(deadline: $someDate) { duration in
CountdownValue3D(timerString: duration)
}
}
}
fileprivate var durationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.year, .month, .weekOfMonth, .day, .hour, .minute, .second]
formatter.unitsStyle = .positional
formatter.zeroFormattingBehavior = .default
return formatter
}()
fileprivate struct CountdownWrapper<Content: View>: View {
@Binding var deadline: Date
@State private var duration = ""
@State private var repeatingTimer: AnyCancellable?
let content: (Binding<String>) -> Content
var body: some View {
VStack {
if duration.isEmpty {
ProgressView()
} else {
content($duration)
}
}
.onAppear {
startInitialTimer()
}
.onDisappear {
stopRepeatingTimer()
}
}
private func startInitialTimer() {
let now = Date()
let nextFullSecond = ceil(now.timeIntervalSinceReferenceDate)
let delay = nextFullSecond - now.timeIntervalSinceReferenceDate
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.startRepeatingTimer()
self.updateDuration()
}
}
private func startRepeatingTimer() {
repeatingTimer = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { _ in
self.updateDuration()
}
}
private func stopRepeatingTimer() {
repeatingTimer?.cancel()
}
private func updateDuration() {
// TODO: I've seen crashes here with a warning about "index out of range"
// NOTE: This stopped happening when I removed the countdown from the list view
var delta = deadline.timeIntervalSince(Date())
if delta <= 0 {
delta = 0
stopRepeatingTimer()
}
duration = durationFormatter.string(from: delta) ?? ""
}
}
fileprivate struct CountdownValue3D: View {
@Binding var timerString: String
var body: some View {
ZStack {
GeometryReader3D { proxy in
CountdownValue3DScene(timerString: $timerString, proxy: proxy)
.frame(idealHeight: 30)
}
}
.frame(depth: 100, alignment: DepthAlignment.back)
}
}
fileprivate struct CountdownValue3DScene: View {
@Binding var timerString: String
var proxy: GeometryProxy3D
@State private var modelEntity: ModelEntity?
var body: some View {
RealityView { content in
if modelEntity == nil {
let entity = makeTextEntity(timerString: timerString)
modelEntity = entity
entity.scaleToFit(proxy: proxy, content: content, yOffset: 0.02)
entity.name = "text3D"
content.add(entity)
}
} update: { content in
for entity in content.entities {
if entity.name == "text3D", let textEntity = entity as? ModelEntity {
textEntity.model = makeTextEntity(timerString: timerString).model
textEntity.scaleToFit(proxy: proxy, content: content, yOffset: 0.02)
}
}
}
}
@MainActor private let faceMaterial: PhysicallyBasedMaterial = {
var faceMaterial = PhysicallyBasedMaterial()
faceMaterial.metallic = .init(floatLiteral: 1)
faceMaterial.baseColor = .init(tint: #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1))
return faceMaterial
}()
@MainActor private let borderMaterial: PhysicallyBasedMaterial = {
var borderMaterial = PhysicallyBasedMaterial()
borderMaterial.metallic = .init(floatLiteral: 0.15)
borderMaterial.roughness = .init(floatLiteral: 0.85)
borderMaterial.baseColor = .init(tint: #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1))
return borderMaterial
}()
private func makeTextEntity(timerString: String) -> ModelEntity {
var textString = AttributedString(timerString)
textString.font = .monospacedSystemFont(ofSize: 8.0, weight: .heavy)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
textString.mergeAttributes(AttributeContainer([.paragraphStyle: paragraphStyle]))
var textOptions = MeshResource.GenerateTextOptions()
textOptions.containerFrame = CGRect(x: 0, y: 0, width: 100, height: 50)
var extrusionOptions = MeshResource.ShapeExtrusionOptions()
extrusionOptions.extrusionMethod = .linear(depth: 1)
extrusionOptions.materialAssignment = .init(front: 0, back: 0, extrusion: 1, frontChamfer: 1, backChamfer: 1)
extrusionOptions.chamferRadius = 0.1
let textMesh = try! MeshResource(extruding: textString,
textOptions: textOptions,
extrusionOptions: extrusionOptions)
return ModelEntity(mesh: textMesh, materials: [faceMaterial, borderMaterial])
}
}
fileprivate extension Entity {
func scaleToFit(proxy: GeometryProxy3D, content: RealityViewContent, scaler: Float = 0.8, yOffset: Float = 0.0) {
guard let model = components[ModelComponent.self] else { return }
let frame = proxy.frame(in: .local)
let frameCenter = content.convert(frame.center, from: .local, to: .scene)
let frameSize = abs(content.convert(frame.size, from: .local, to: .scene))
let bounds = model.mesh.bounds
let extents = bounds.extents
let center = bounds.center
let graphicScale = min(frameSize.x / extents.x, frameSize.y / extents.y)
scale = SIMD3<Float>(repeating: graphicScale * scaler)
position = frameCenter - SIMD3<Float>(0, 0 + yOffset, 0)
position -= center * (graphicScale * scaler)
}
}Support our work so we can continue to bring you new examples and articles.

Follow Step Into Vision