Onboarding Motion Handoff

入场与助手问候
Success to assistant greeting
Single-file componentShared motion specBuild checked
import SwiftUI
#if canImport(SDWebImageSwiftUI)
import SDWebImageSwiftUI
#endif
#if canImport(SDWebImageWebPCoder)
import SDWebImage
import SDWebImageWebPCoder
#endif
/// Complete iOS 17+ onboarding motion.
/// Add assistant-wave-transparent-bright.webp as a Bundle Resource; add the
/// neutral avatar and provider icons to Assets.xcassets. Animated playback uses
/// SDWebImageSwiftUI 3.1.4 + SDWebImageWebPCoder 0.9.1.
struct OnboardingMotionView: View {
fileprivate enum Scene: Hashable { case success, greeting, email }
private struct PlaybackKey: Hashable { let scene: Scene; let id: Int }
fileprivate enum Motion {
static let teamAutoAdvance = Duration.milliseconds(850)
static let greetingHold = Duration.milliseconds(4074)
static let sceneEnter = Double(460) / 1000
static let wordStagger = Duration.milliseconds(90)
static let avatarMorph = Double(680) / 1000
static let cardStagger = Double(120) / 1000
static let enter = Animation.timingCurve(0.16, 1, 0.3, 1, duration: sceneEnter)
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var scene: Scene = .success
@State private var playbackID = 0
init() {
#if canImport(SDWebImageWebPCoder)
_ = Self.webPCoderConfigured
#endif
}
#if canImport(SDWebImageWebPCoder)
private static let webPCoderConfigured: Void = {
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
}()
#endif
var body: some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.91, green: 0.95, blue: 1), Color(red: 0.75, green: 0.85, blue: 1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
SuccessScreen(action: { scene = .greeting })
.opacity(scene == .success ? 1 : 0)
.offset(y: scene == .success || reduceMotion ? 0 : -14)
.scaleEffect(scene == .success || reduceMotion ? 1 : 0.985)
SharedAssistant(scene: scene, reduceMotion: reduceMotion)
GreetingCopy(active: scene == .greeting, playbackID: playbackID)
.opacity(scene == .greeting ? 1 : 0)
EmailConnectScreen(active: scene == .email, playbackID: playbackID)
.opacity(scene == .email ? 1 : 0)
}
.animation(reduceMotion ? nil : Motion.enter, value: scene)
.task(id: PlaybackKey(scene: scene, id: playbackID)) {
switch scene {
case .success:
try? await Task.sleep(for: reduceMotion ? .zero : Motion.teamAutoAdvance)
guard !Task.isCancelled, scene == .success else { return }
scene = .greeting
case .greeting:
try? await Task.sleep(for: reduceMotion ? .milliseconds(50) : Motion.greetingHold)
guard !Task.isCancelled, scene == .greeting else { return }
scene = .email
case .email:
break
}
}
.accessibilityAction(named: "Replay") {
playbackID += 1
scene = .success
}
}
}
private struct SuccessScreen: View {
let action: () -> Void
var body: some View {
VStack(spacing: 0) {
Image(systemName: "checkmark")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color(red: 0.04, green: 0.77, blue: 0.42), in: Circle())
.padding(.bottom, 18)
Text("Team created")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(red: 0.01, green: 0.03, blue: 0.12))
Text("Welcome aboard — invite your team\nanytime from Settings.")
.font(.system(size: 15))
.foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
.multilineTextAlignment(.center)
.padding(.top, 10)
Button("Continue", action: action)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 220, height: 50)
.background(Color(red: 0.01, green: 0.03, blue: 0.12), in: Capsule())
.padding(.top, 28)
}
}
}
private struct SharedAssistant: View {
let scene: OnboardingMotionView.Scene
let reduceMotion: Bool
var body: some View {
ZStack {
Circle().fill(.white)
if scene == .success {
EmptyView()
} else if scene == .greeting {
AssistantWaveAsset().clipShape(Circle())
} else {
Image("assistant-neutral")
.resizable().scaledToFill().clipShape(Circle())
}
}
.frame(width: 245, height: 245)
.scaleEffect(scene == .success ? 0.001 : (scene == .email ? CGFloat(120) / 245 : 1))
.offset(y: scene == .email ? -188 : -72)
.animation(
reduceMotion ? nil : .timingCurve(0.16, 1, 0.3, 1, duration: OnboardingMotionView.Motion.avatarMorph),
value: scene
)
.allowsHitTesting(false)
}
}
private struct AssistantWaveAsset: View {
var body: some View {
#if canImport(SDWebImageSwiftUI)
AnimatedImage(name: "assistant-wave-transparent-bright.webp")
.resizable().customLoopCount(1).scaledToFill()
#else
Image("assistant-wave-fallback")
.resizable().scaledToFill()
#endif
}
}
private struct GreetingCopy: View {
let active: Bool
let playbackID: Int
@State private var visibleWords = 0
private let words = ["Hi,", "I am", "your", "AI", "Assistant"]
var body: some View {
VStack(spacing: 10) {
HStack(spacing: 6) {
ForEach(Array(words.prefix(3).enumerated()), id: \.offset) { index, word in
wordView(word, index: index, size: 24)
}
}
HStack(spacing: 8) {
ForEach(Array(words.suffix(2).enumerated()), id: \.offset) { index, word in
wordView(word, index: index + 3, size: 36)
}
}
}
.offset(y: 116)
.task(id: "\(active)-\(playbackID)") {
visibleWords = 0
guard active else { return }
for count in 1...words.count {
withAnimation(OnboardingMotionView.Motion.enter) { visibleWords = count }
try? await Task.sleep(for: OnboardingMotionView.Motion.wordStagger)
if Task.isCancelled { return }
}
}
}
private func wordView(_ word: String, index: Int, size: CGFloat) -> some View {
Text(word)
.font(.system(size: size, weight: .medium))
.foregroundStyle(Color(red: 0.01, green: 0.03, blue: 0.12))
.opacity(visibleWords > index ? 1 : 0)
.offset(y: visibleWords > index ? 0 : 12)
}
}
private struct EmailConnectScreen: View {
let active: Bool
let playbackID: Int
@State private var visibleWords = 0
private let titleWords = ["Connect", "your", "work", "email"]
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
ForEach(Array(titleWords.enumerated()), id: \.offset) { index, word in
Text(word)
.font(.system(size: 32, weight: .medium))
.opacity(visibleWords > index ? 1 : 0)
.offset(y: visibleWords > index ? 0 : 12)
}
}
.frame(maxWidth: 350)
.padding(.top, 214)
Text("The more I understand your work,\nthe better I can help")
.font(.system(size: 16))
.foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
.multilineTextAlignment(.center)
.padding(.top, 12)
VStack(spacing: 12) {
ProviderCard(name: "Gmail", asset: "gmail", delay: 0, active: active)
ProviderCard(name: "Outlook", asset: "outlook", delay: OnboardingMotionView.Motion.cardStagger, active: active)
}
.padding(.horizontal, 24)
.padding(.top, 42)
Spacer()
Label("Your data stays private and secure", systemImage: "shield")
.font(.system(size: 13))
.foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
.padding(.bottom, 38)
}
.task(id: "email-\(active)-\(playbackID)") {
visibleWords = 0
guard active else { return }
for count in 1...titleWords.count {
withAnimation(OnboardingMotionView.Motion.enter) { visibleWords = count }
try? await Task.sleep(for: OnboardingMotionView.Motion.wordStagger)
if Task.isCancelled { return }
}
}
}
}
private struct ProviderCard: View {
let name: String
let asset: String
let delay: Double
let active: Bool
var body: some View {
HStack(spacing: 14) {
Image(asset).resizable().scaledToFit().frame(width: 28, height: 28)
.frame(width: 50, height: 50).background(.white, in: RoundedRectangle(cornerRadius: 14))
Text(name).font(.system(size: 17, weight: .medium))
Spacer()
Text("Link").font(.system(size: 14, weight: .medium)).foregroundStyle(.white)
.padding(.horizontal, 18).frame(height: 38).background(Color(red: 0.01, green: 0.03, blue: 0.12), in: Capsule())
}
.padding(.horizontal, 16).frame(height: 82).background(.white, in: RoundedRectangle(cornerRadius: 18))
.opacity(active ? 1 : 0).offset(y: active ? 0 : 18)
.animation(OnboardingMotionView.Motion.enter.delay(delay), value: active)
}
}
Animation asset
头像挥手动画下载包
Assistant wave package
邮件分析进度
Seven seconds of visible progress
Single-file componentShared motion specBuild checked
import SwiftUI
/// Complete iOS 17+ seven-second email analysis motion.
struct EmailAnalysisView: View {
private enum Motion {
static let duration = Double(7000) / 1000
static let cardsEnter = Double(300) / 1000
static let shimmerCycle = Double(1600) / 1000
static let targets = [214,49,21]
static let enter = Animation.timingCurve(0.16, 1, 0.3, 1, duration: cardsEnter)
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var progress = 0.0
@State private var cardsVisible = false
@State private var playbackID = 0
private let rows = [
("Messages found", "envelope", 0),
("Sent by you", "paperplane", 1),
("Read Closely", "eyeglasses", 2)
]
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.92, green: 0.96, blue: 1), .white], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack(spacing: 0) {
HStack(spacing: 24) {
CircleAsset(name: "gmail", size: 92)
DataFlow(progress: progress)
.frame(width: 48, height: 46)
CircleAsset(name: "assistant-neutral", size: 92)
}
.padding(.top, 110)
ShimmerText("Reading recent emails...", cycle: Motion.shimmerCycle)
.font(.system(size: 20, weight: .semibold))
.padding(.top, 30)
Text("Your AI assistant is reading your work content")
.font(.system(size: 14))
.foregroundStyle(Color(red: 0.52, green: 0.59, blue: 0.69))
.padding(.top, 8)
ProgressView(value: progress)
.tint(Color(red: 0.46, green: 0.65, blue: 1))
.padding(.horizontal, 42)
.padding(.top, 18)
VStack(spacing: 10) {
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
MetricCard(title: row.0, symbol: row.1, value: Double(Motion.targets[row.2]) * progress)
.opacity(cardsVisible ? 1 : 0)
.offset(y: cardsVisible ? 0 : 10)
.animation(Motion.enter.delay(Double(rowIndex) * 0.06), value: cardsVisible)
}
}
.padding(.horizontal, 24)
.padding(.top, 42)
Spacer()
}
}
.task(id: playbackID) {
progress = 0
cardsVisible = false
if reduceMotion {
cardsVisible = true
progress = 1
return
}
withAnimation(Motion.enter) { cardsVisible = true }
withAnimation(.linear(duration: Motion.duration)) { progress = 1 }
}
.accessibilityAction(named: "Replay") { playbackID += 1 }
}
}
private struct CircleAsset: View {
let name: String
let size: CGFloat
var body: some View {
Image(name).resizable().scaledToFit().padding(18)
.frame(width: size, height: size).background(.white, in: Circle())
}
}
private struct DataFlow: View {
let progress: Double
var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, size in
let phase = timeline.date.timeIntervalSinceReferenceDate * -12
var paths = [Path]()
var top = Path(); top.move(to: .zero); top.addCurve(to: CGPoint(x: size.width, y: 18), control1: CGPoint(x: 18, y: 14), control2: CGPoint(x: 30, y: 18)); paths.append(top)
var middle = Path(); middle.move(to: CGPoint(x: 0, y: 23)); middle.addLine(to: CGPoint(x: size.width, y: 23)); paths.append(middle)
var bottom = Path(); bottom.move(to: CGPoint(x: 0, y: size.height)); bottom.addCurve(to: CGPoint(x: size.width, y: 28), control1: CGPoint(x: 18, y: 32), control2: CGPoint(x: 30, y: 28)); paths.append(bottom)
for path in paths {
context.stroke(path, with: .color(Color(red: 0, green: 0.37, blue: 1).opacity(0.2)), style: StrokeStyle(lineWidth: 1.5, dash: [4, 4], dashPhase: phase))
}
}
}
.accessibilityHidden(true)
}
}
private struct MetricCard: View {
let title: String
let symbol: String
let value: Double
var body: some View {
HStack(spacing: 14) {
Image(systemName: symbol).frame(width: 44, height: 44)
.background(Color(red: 0.94, green: 0.96, blue: 0.99), in: RoundedRectangle(cornerRadius: 12))
Text(title).font(.system(size: 16)).foregroundStyle(.secondary)
Spacer()
CountingNumber(value: value).font(.system(size: 28, weight: .semibold).monospacedDigit())
}
.padding(.horizontal, 18).frame(height: 82).background(.white, in: RoundedRectangle(cornerRadius: 18))
}
}
private struct CountingNumber: View, Animatable {
var value: Double
var animatableData: Double { get { value } set { value = newValue } }
var body: some View { Text(Int(value.rounded()).formatted()) }
}
private struct ShimmerText: View {
let text: String
let cycle: Double
init(_ text: String, cycle: Double) { self.text = text; self.cycle = cycle }
var body: some View {
TimelineView(.animation) { context in
let phase = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycle) / cycle
Text(text).foregroundStyle(Color.gray.opacity(0.45))
.overlay {
LinearGradient(colors: [.clear, .white.opacity(0.9), .clear], startPoint: .leading, endPoint: .trailing)
.offset(x: CGFloat(phase * 320 - 160)).mask(Text(text))
}
}
}
}
被邀请者气泡动画
Invitee context bubbles
Single-file componentShared motion specBuild checked
import SwiftUI
#if canImport(SDWebImageSwiftUI)
import SDWebImageSwiftUI
#endif
#if canImport(SDWebImageWebPCoder)
import SDWebImage
import SDWebImageWebPCoder
#endif
/// Complete iOS 17+ invitee sequence: joined → greeting → email → floating bubbles.
struct InviteeBubbleMotionView: View {
fileprivate enum Scene: Hashable { case joined, greeting, email }
fileprivate struct Bubble: Identifiable {
let id: Int; let label: String; let value: String; let symbol: String
}
private enum Motion {
static let teamAdvance = Duration.milliseconds(850)
static let greetingHold = Duration.milliseconds(4300)
static let firstBubbleDelay = Duration.milliseconds(900)
static let bubbleHold = Duration.milliseconds(3600)
static let bubbleExit = Double(1400) / 1000
static let handoff = Duration.milliseconds(900)
static let enter = Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.46)
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var scene: Scene = .joined
@State private var bubbleIndex = 0
@State private var bubbleVisible = false
@State private var bubbleLeaving = false
@State private var playbackID = 0
init() {
#if canImport(SDWebImageWebPCoder)
_ = Self.webPCoderConfigured
#endif
}
#if canImport(SDWebImageWebPCoder)
private static let webPCoderConfigured: Void = {
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
}()
#endif
private let bubbles = [
Bubble(id: 0, label: "Organization", value: "Tanka", symbol: "building.2"),
Bubble(id: 1, label: "Chat Group", value: "Product Workspace", symbol: "bubble.left.and.bubble.right"),
Bubble(id: 2, label: "Role", value: "Product Lead", symbol: "person.crop.circle")
]
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.91, green: 0.95, blue: 1), .white], startPoint: .top, endPoint: .bottom).ignoresSafeArea()
InviteeBaseContent(scene: scene)
if scene == .email {
ContextBubbleCard(item: bubbles[bubbleIndex])
.opacity(bubbleVisible && !bubbleLeaving ? 1 : 0)
.scaleEffect(bubbleVisible && !bubbleLeaving ? 1 : 0.985)
.offset(y: bubbleLeaving && !reduceMotion ? -56 : (bubbleVisible ? -310 : -300))
.animation(
reduceMotion ? .easeOut(duration: 0.15) : .timingCurve(0.16, 1, 0.3, 1, duration: bubbleLeaving ? Motion.bubbleExit : 0.42),
value: bubbleLeaving
)
.animation(Motion.enter, value: bubbleVisible)
}
}
.animation(reduceMotion ? nil : Motion.enter, value: scene)
.task(id: "\(scene)-\(playbackID)") {
switch scene {
case .joined:
try? await Task.sleep(for: reduceMotion ? .zero : Motion.teamAdvance)
guard !Task.isCancelled else { return }; scene = .greeting
case .greeting:
try? await Task.sleep(for: reduceMotion ? .milliseconds(50) : Motion.greetingHold)
guard !Task.isCancelled else { return }; scene = .email
case .email:
try? await Task.sleep(for: reduceMotion ? .milliseconds(100) : Motion.firstBubbleDelay)
while !Task.isCancelled {
bubbleLeaving = false
withAnimation(Motion.enter) { bubbleVisible = true }
try? await Task.sleep(for: reduceMotion ? .milliseconds(800) : Motion.bubbleHold)
withAnimation { bubbleLeaving = true }
try? await Task.sleep(for: reduceMotion ? .milliseconds(150) : Motion.handoff)
bubbleVisible = false
bubbleIndex = (bubbleIndex + 1) % bubbles.count
}
}
}
.accessibilityAction(named: "Replay") {
bubbleVisible = false; bubbleLeaving = false; bubbleIndex = 0
playbackID += 1; scene = .joined
}
}
}
private struct InviteeBaseContent: View {
let scene: InviteeBubbleMotionView.Scene
var body: some View {
VStack(spacing: 0) {
Spacer().frame(height: 170)
ZStack {
Circle().fill(.white)
if scene == .joined {
Image(systemName: "checkmark").font(.system(size: 30, weight: .semibold)).foregroundStyle(.green)
} else if scene == .greeting {
#if canImport(SDWebImageSwiftUI)
AnimatedImage(name: "assistant-wave-transparent-bright.webp").resizable().customLoopCount(1).scaledToFill()
#else
Image("assistant-wave-fallback").resizable().scaledToFill()
#endif
} else {
Image("assistant-neutral").resizable().scaledToFill()
}
}
.clipShape(Circle()).frame(width: scene == .greeting ? 245 : 120, height: scene == .greeting ? 245 : 120)
if scene == .joined {
Text("You’ve joined Tanka").font(.system(size: 22, weight: .semibold)).padding(.top, 20)
} else if scene == .greeting {
Text("Hi, I am your").font(.system(size: 24, weight: .medium)).padding(.top, 22)
Text("AI Assistant").font(.system(size: 36, weight: .medium)).padding(.top, 8)
} else {
Text("Connect your work email").font(.system(size: 32, weight: .medium)).padding(.top, 24)
Text("The more I understand your work, the better I can help")
.font(.system(size: 15)).foregroundStyle(.secondary).padding(.top, 10)
VStack(spacing: 12) {
InviteeProvider(name: "Gmail", image: "gmail")
InviteeProvider(name: "Outlook", image: "outlook")
}.padding(.horizontal, 24).padding(.top, 38)
}
Spacer()
}
}
}
private struct InviteeProvider: View {
let name: String; let image: String
var body: some View {
HStack { Image(image).resizable().scaledToFit().frame(width: 28, height: 28); Text(name).fontWeight(.medium); Spacer(); Text("Link").foregroundStyle(.white).padding(.horizontal, 18).frame(height: 38).background(.black, in: Capsule()) }
.padding(.horizontal, 18).frame(height: 82).background(.white, in: RoundedRectangle(cornerRadius: 18))
}
}
private struct ContextBubbleCard: View {
let item: InviteeBubbleMotionView.Bubble
@State private var floating = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: item.symbol).frame(width: 40, height: 40).background(.white.opacity(0.8), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 4) {
Text(item.label.uppercased()).font(.system(size: 13, weight: .medium)).foregroundStyle(.secondary)
Text(item.value).font(.system(size: 14, weight: .medium))
}
}
.padding(.horizontal, 16).frame(width: 220, height: 72)
.background(.white.opacity(0.44), in: RoundedRectangle(cornerRadius: 24))
.offset(y: floating ? -3 : 3)
.onAppear { withAnimation(.easeInOut(duration: 3.2).repeatForever(autoreverses: true)) { floating = true } }
}
}
进入 Tanka 首页
From onboarding chat to Tanka home
Single-file componentShared motion specBuild checked
import SwiftUI
/// iOS 17+ handoff for the approved 393 × 852 Join Tanka sequence.
/// Add the exported `assistant-top`, `plus`, `mic`, `history`, and tab icons to Assets.xcassets.
struct JoinTankaMotionView: View {
private enum Phase: Hashable {
case answering
case joinReady
case enteringHome
case home
}
private enum Motion {
static let streamStart = Duration.milliseconds(350)
static let wordInterval = Duration.milliseconds(45)
static let answerHold = Duration.milliseconds(550)
static let joinLead = Duration.milliseconds(70)
static let footerHandoff = Duration.milliseconds(450)
static let homeSettle = Duration.milliseconds(900)
static let firstUpdate = Duration.milliseconds(1900)
static let secondUpdate = Duration.milliseconds(1900)
static let drawer = Animation.timingCurve(0.32, 0.72, 0, 1, duration: Double(360) / 1000)
static let fade = Animation.timingCurve(0.23, 1, 0.32, 1, duration: 0.22)
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var phase: Phase = .answering
@State private var streamedWordCount = 0
@State private var composerVisible = true
@State private var joinVisible = false
@State private var firstUpdateVisible = false
@State private var secondUpdateVisible = false
@State private var playbackID = 0
private let answerWords = "every conversation with people inside and outside Tanka is aggregated there, and you can hand any of it off to me at any time."
.split(separator: " ")
.map(String.init)
var body: some View {
ZStack(alignment: .bottom) {
Color(red: 0.953, green: 0.969, blue: 0.988)
.ignoresSafeArea()
VStack(spacing: 0) {
statusBar
navigationBar
content
}
LinearGradient(
colors: [.clear, Color(red: 0.953, green: 0.969, blue: 0.988)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: phase == .home ? 216 : 150)
.allowsHitTesting(false)
if composerVisible {
composer
.padding(.horizontal, 16)
.padding(.bottom, 50)
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
}
if joinVisible {
Button("Join Tanka", action: enterTanka)
.buttonStyle(JoinButtonStyle())
.padding(.horizontal, 16)
.padding(.bottom, 50)
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
}
if phase == .enteringHome || phase == .home {
homeFooter
.padding(.bottom, 28)
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
}
}
.frame(width: 393, height: 852)
.clipShape(RoundedRectangle(cornerRadius: 50, style: .continuous))
.task(id: playbackID) { await playSequence() }
.accessibilityAction(named: "Replay") { replay() }
}
private var statusBar: some View {
HStack {
Text("9:41").font(.system(size: 17, weight: .medium))
Spacer()
HStack(spacing: 7) {
Image(systemName: "cellularbars")
Image(systemName: "wifi")
Image(systemName: "battery.100")
}
.font(.system(size: 15, weight: .medium))
}
.padding(.horizontal, 34)
.frame(height: 62)
}
private var navigationBar: some View {
ZStack {
HStack {
if phase == .answering || phase == .joinReady {
Spacer()
Text("Skip").font(.system(size: 16))
} else {
HStack(spacing: 4) {
Text("Shanda").font(.system(size: 17, weight: .medium))
Image(systemName: "chevron.down")
.font(.system(size: 12, weight: .medium))
}
Spacer()
Image("history").resizable().scaledToFit().frame(width: 82, height: 24)
}
}
Image("assistant-top")
.resizable()
.scaledToFill()
.frame(width: phase == .home ? 40 : 44, height: phase == .home ? 40 : 44)
.clipShape(Circle())
.background(.white, in: Circle())
}
.padding(.horizontal, 18)
.frame(height: 64)
.animation(reduceMotion ? Motion.fade : Motion.drawer, value: phase)
}
@ViewBuilder private var content: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 24) {
if phase == .answering || phase == .joinReady {
onboardingAnswer
} else {
homeConversation
}
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, phase == .home ? 210 : 146)
}
.scrollIndicators(.hidden)
.onChange(of: secondUpdateVisible) { _, visible in
guard visible else { return }
withAnimation(reduceMotion ? Motion.fade : Motion.drawer) {
proxy.scrollTo("latest-update", anchor: .bottom)
}
}
}
}
private var onboardingAnswer: some View {
VStack(alignment: .leading, spacing: 24) {
Text("Chat — ").fontWeight(.medium) +
Text(answerWords.prefix(streamedWordCount).joined(separator: " "))
}
.font(.system(size: 17, weight: .regular))
.lineSpacing(5)
}
private var homeConversation: some View {
VStack(alignment: .leading, spacing: 24) {
Text("You’re inside Tanka. I’ll keep reporting back in this conversation.")
if firstUpdateVisible {
Text("The research Subflow has already found three active objects inside Onboarding 2.0 改版: a launch-plan document, a Jira delivery board and Marubishi’s unanswered rollout email.")
.transition(messageTransition)
}
if secondUpdateVisible {
Text("That focused research surfaced two useful follow-ups, so I delegated a private reply draft and a weekly project watch. They’ll report progress back through me.")
.id("latest-update")
.transition(messageTransition)
}
}
.font(.system(size: 17, weight: .regular))
.lineSpacing(5)
}
private var messageTransition: AnyTransition {
reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)
}
private var composer: some View {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image("plus").resizable().scaledToFit().frame(width: 24, height: 24)
Text("Type a message")
.foregroundStyle(Color(red: 0.435, green: 0.498, blue: 0.58))
Spacer()
}
Image("mic").resizable().scaledToFit().frame(width: 40, height: 40)
}
.padding(.horizontal, 12)
.frame(height: 68)
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().stroke(.white.opacity(0.8), lineWidth: 1))
}
private var homeFooter: some View {
VStack(spacing: 20) {
composer
.padding(.horizontal, 16)
HStack(spacing: 0) {
TankaTab(title: "Flow", symbol: "square.grid.2x2.fill", selected: true)
TankaTab(title: "Chat", symbol: "bubble.left", selected: false)
TankaTab(title: "Link", symbol: "link", selected: false)
}
.padding(4)
.frame(width: 294, height: 62)
.background(.ultraThinMaterial, in: Capsule())
}
}
private func playSequence() async {
resetSequence()
if reduceMotion {
streamedWordCount = answerWords.count
} else {
try? await Task.sleep(for: Motion.streamStart)
for index in answerWords.indices {
guard !Task.isCancelled else { return }
streamedWordCount = index + 1
try? await Task.sleep(for: Motion.wordInterval)
}
}
try? await Task.sleep(for: reduceMotion ? .milliseconds(120) : Motion.answerHold)
guard !Task.isCancelled else { return }
withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { composerVisible = false }
try? await Task.sleep(for: reduceMotion ? .milliseconds(20) : Motion.joinLead)
guard !Task.isCancelled else { return }
withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { joinVisible = true }
try? await Task.sleep(for: reduceMotion ? .milliseconds(200) : Motion.footerHandoff)
phase = .joinReady
}
private func enterTanka() {
guard phase == .joinReady else { return }
withAnimation(reduceMotion ? Motion.fade : Motion.drawer) {
joinVisible = false
phase = .enteringHome
}
Task {
try? await Task.sleep(for: reduceMotion ? .milliseconds(120) : Motion.homeSettle)
guard !Task.isCancelled else { return }
withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { phase = .home }
try? await Task.sleep(for: reduceMotion ? .milliseconds(200) : Motion.firstUpdate)
guard !Task.isCancelled else { return }
withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { firstUpdateVisible = true }
try? await Task.sleep(for: reduceMotion ? .milliseconds(300) : Motion.secondUpdate)
guard !Task.isCancelled else { return }
withAnimation(reduceMotion ? Motion.fade : Motion.drawer) { secondUpdateVisible = true }
}
}
private func resetSequence() {
phase = .answering
streamedWordCount = 0
composerVisible = true
joinVisible = false
firstUpdateVisible = false
secondUpdateVisible = false
}
private func replay() {
playbackID += 1
}
}
private struct JoinButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(Color(red: 0.008, green: 0.024, blue: 0.09), in: Capsule())
.scaleEffect(configuration.isPressed ? 0.985 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
}
private struct TankaTab: View {
let title: String
let symbol: String
let selected: Bool
var body: some View {
VStack(spacing: 3) {
Image(systemName: symbol).font(.system(size: 22, weight: .medium))
Text(title).font(.system(size: 11, weight: .medium))
}
.foregroundStyle(selected ? Color(red: 0.208, green: 0.224, blue: 0.298) : .secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(selected ? Color(red: 0.925, green: 0.945, blue: 0.961) : .clear, in: Capsule())
}
}