refine iOS swipe flow interaction
This commit is contained in:
@@ -8,14 +8,24 @@ final class PlayerCoordinator: ObservableObject {
|
||||
|
||||
@Published var isPlaying = false
|
||||
@Published var playbackPositionMs: Int = 0
|
||||
@Published var playbackCompletionCount = 0
|
||||
|
||||
private var playbackEndObserver: NSObjectProtocol?
|
||||
|
||||
init() {
|
||||
self.player = AVPlayer()
|
||||
self.player.actionAtItemEnd = .pause
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let playbackEndObserver {
|
||||
NotificationCenter.default.removeObserver(playbackEndObserver)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareForPreview(url: URL, startTimeMs: Int = 0) {
|
||||
player.replaceCurrentItem(with: AVPlayerItem(url: url))
|
||||
observePlaybackEnd(for: player.currentItem)
|
||||
let startTime = CMTime(seconds: Double(startTimeMs) / 1_000.0, preferredTimescale: 1_000)
|
||||
player.seek(to: startTime)
|
||||
player.play()
|
||||
@@ -50,4 +60,26 @@ final class PlayerCoordinator: ObservableObject {
|
||||
func restart(url: URL, startTimeMs: Int = 0) {
|
||||
prepareForPreview(url: url, startTimeMs: startTimeMs)
|
||||
}
|
||||
|
||||
private func observePlaybackEnd(for item: AVPlayerItem?) {
|
||||
if let playbackEndObserver {
|
||||
NotificationCenter.default.removeObserver(playbackEndObserver)
|
||||
self.playbackEndObserver = nil
|
||||
}
|
||||
|
||||
guard let item else {
|
||||
return
|
||||
}
|
||||
|
||||
playbackEndObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.isPlaying = false
|
||||
self?.playbackCompletionCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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