refine iOS swipe flow interaction

This commit is contained in:
2026-04-10 11:25:42 +02:00
parent a4d77078a4
commit 392116bf28
6 changed files with 150 additions and 41 deletions
+65 -41
View File
@@ -11,7 +11,6 @@ struct RoundView: View {
@State private var phase: Phase = .preview
@State private var selectedOutcomeID: String?
@State private var lockAt: Date = .now
@State private var transitionTask: Task<Void, Never>?
@State private var actionMessage: String?
@State private var isSubmitting = false
@State private var swipeFeedback: SwipeFeedback?
@@ -63,6 +62,8 @@ struct RoundView: View {
)
.ignoresSafeArea()
sideSelectionOverlay(round: round)
if let feedback = swipeFeedback {
swipeFeedbackOverlay(feedback)
}
@@ -99,9 +100,15 @@ struct RoundView: View {
phase = .locked
playerCoordinator.pause()
}
.onChange(of: playerCoordinator.playbackCompletionCount) { _, _ in
guard phase == .locked, let round, selectedOutcomeID != nil else {
return
}
startReveal(for: round)
}
}
.onDisappear {
transitionTask?.cancel()
playerCoordinator.pause()
}
}
@@ -131,7 +138,7 @@ struct RoundView: View {
if let round {
switch phase {
case .preview:
swipeHintRow(round: round)
EmptyView()
case .locked:
lockedOverlay(timerLocked: timerLocked)
case .reveal:
@@ -155,32 +162,6 @@ struct RoundView: View {
.padding(.bottom, 28)
}
private func swipeHintRow(round: HermesRound) -> some View {
let leftOutcome = sortedOutcomes(for: round).dropFirst().first
let rightOutcome = sortedOutcomes(for: round).first
return HStack(spacing: 12) {
swipeEdgeLabel(title: leftOutcome.map(outcomeTitle) ?? localization.string(for: "round.no"), alignment: .leading, color: .red)
swipeEdgeLabel(title: rightOutcome.map(outcomeTitle) ?? localization.string(for: "round.yes"), alignment: .trailing, color: HermesTheme.positive)
}
}
private func swipeEdgeLabel(title: String, alignment: HorizontalAlignment, color: Color) -> some View {
VStack(alignment: alignment, spacing: 6) {
Text(title)
.font(.title3.weight(.bold))
.foregroundStyle(.white)
Text(alignment == .leading ? localization.string(for: "round.swipe_left" ) : localization.string(for: "round.swipe_right"))
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.72))
}
.frame(maxWidth: .infinity, alignment: alignment == .leading ? .leading : .trailing)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(color.opacity(0.18))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
private func lockedOverlay(timerLocked: Bool) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(localization.string(for: timerLocked ? "round.freeze_title" : "round.locked_label"))
@@ -259,6 +240,52 @@ struct RoundView: View {
.frame(maxWidth: .infinity)
}
@ViewBuilder
private func sideSelectionOverlay(round: HermesRound?) -> some View {
if let round, phase == .preview {
let dragThreshold: CGFloat = 18
let isDraggingRight = dragOffset.width > dragThreshold
let isDraggingLeft = dragOffset.width < -dragThreshold
HStack(spacing: 0) {
sideTint(
title: sortedOutcomes(for: round).dropFirst().first.map(outcomeTitle) ?? localization.string(for: "round.no"),
subtitle: localization.string(for: "round.swipe_left"),
color: .red,
opacity: isDraggingLeft ? min(abs(dragOffset.width) / 180.0, 0.78) : 0
)
sideTint(
title: sortedOutcomes(for: round).first.map(outcomeTitle) ?? localization.string(for: "round.yes"),
subtitle: localization.string(for: "round.swipe_right"),
color: HermesTheme.positive,
opacity: isDraggingRight ? min(abs(dragOffset.width) / 180.0, 0.78) : 0
)
}
.ignoresSafeArea()
.allowsHitTesting(false)
} else {
EmptyView()
}
}
private func sideTint(title: String, subtitle: String, color: Color, opacity: Double) -> some View {
ZStack(alignment: .center) {
color.opacity(opacity)
if opacity > 0.05 {
VStack(spacing: 8) {
Text(title)
.font(.system(size: 30, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text(subtitle)
.font(.caption.weight(.bold))
.foregroundStyle(.white.opacity(0.88))
}
.padding(24)
}
}
.frame(maxWidth: .infinity)
}
private func swipeGesture(round: HermesRound?, timerLocked: Bool) -> some Gesture {
DragGesture(minimumDistance: 24)
.onChanged { value in
@@ -300,7 +327,6 @@ struct RoundView: View {
}
private func startPreview(_ round: HermesRound) {
transitionTask?.cancel()
phase = .preview
selectedOutcomeID = nil
actionMessage = nil
@@ -397,18 +423,17 @@ struct RoundView: View {
private func beginRevealTransition(for round: HermesRound, selectedOutcomeID: String) {
phase = .locked
playerCoordinator.play(rate: 2.0)
analytics.track("round_accelerated", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
}
transitionTask?.cancel()
transitionTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_100_000_000)
guard !Task.isCancelled else {
return
}
phase = .reveal
playerCoordinator.play(url: revealMediaURL(for: round), startTimeMs: revealStartTimeMs(for: round), rate: 1.0)
analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
private func startReveal(for round: HermesRound) {
guard let selectedOutcomeID else {
return
}
phase = .reveal
playerCoordinator.play(url: revealMediaURL(for: round), startTimeMs: revealStartTimeMs(for: round), rate: 1.0)
analytics.track("reveal_started", attributes: roundAnalyticsAttributes(round).merging(baseSelectionAttributes(selectedOutcomeID)) { _, new in new })
}
private func showResult() {
@@ -423,7 +448,6 @@ struct RoundView: View {
private func nextRound() {
analytics.track("next_round_requested", attributes: ["screen_name": "result"])
transitionTask?.cancel()
actionMessage = nil
Task { @MainActor in