refine iOS swipe flow interaction
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user