Link'n'Sync indeterminate primary spinner

This commit is contained in:
Elaine 2025-01-15 12:11:48 -07:00 committed by GitHub
parent 867bac9f55
commit 1a51bebf69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 243 additions and 82 deletions

View File

@ -1619,6 +1619,7 @@
B99288002CF124AC000D62C4 /* Text+Links.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99287FF2CF124AC000D62C4 /* Text+Links.swift */; };
B99B155D2A71BA5200E26DAC /* StoryContextViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99B155C2A71BA5200E26DAC /* StoryContextViewState.swift */; };
B9A0807A2B07D76A000FDB5B /* HomeTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A080792B07D76A000FDB5B /* HomeTabViewController.swift */; };
B9A47ACE2D36DA6B0024DD9C /* circular_indeterminate.json in Resources */ = {isa = PBXBuildFile; fileRef = B9A47ACD2D36DA6B0024DD9C /* circular_indeterminate.json */; };
B9A53B912CF507FB0000578B /* LinkAndSyncProgressModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B902CF507FB0000578B /* LinkAndSyncProgressModal.swift */; };
B9A53B932CF7928A0000578B /* SheetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B922CF7928A0000578B /* SheetPreviewViewController.swift */; };
B9A53B952CF799590000578B /* LinkOrSyncPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B942CF799590000578B /* LinkOrSyncPickerSheet.swift */; };
@ -5373,6 +5374,7 @@
B99287FF2CF124AC000D62C4 /* Text+Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Links.swift"; sourceTree = "<group>"; };
B99B155C2A71BA5200E26DAC /* StoryContextViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryContextViewState.swift; sourceTree = "<group>"; };
B9A080792B07D76A000FDB5B /* HomeTabViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTabViewController.swift; sourceTree = "<group>"; };
B9A47ACD2D36DA6B0024DD9C /* circular_indeterminate.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = circular_indeterminate.json; sourceTree = "<group>"; };
B9A53B902CF507FB0000578B /* LinkAndSyncProgressModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAndSyncProgressModal.swift; sourceTree = "<group>"; };
B9A53B922CF7928A0000578B /* SheetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetPreviewViewController.swift; sourceTree = "<group>"; };
B9A53B942CF799590000578B /* LinkOrSyncPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkOrSyncPickerSheet.swift; sourceTree = "<group>"; };
@ -10519,6 +10521,7 @@
34848D5D25D43ADD00E5034B /* add-money.json */,
88DBDFBA2638FFBC00C2101C /* audio-played-dot.json */,
34848D5A25D43ADD00E5034B /* cash-out.json */,
B9A47ACD2D36DA6B0024DD9C /* circular_indeterminate.json */,
880FB3EA28CA53D200FA1C10 /* determinate_spinner_44.json */,
880FB3EC28CA53D300FA1C10 /* determinate_spinner_56.json */,
88E8BEEF28D53C3700509CE2 /* indeterminate_spinner_20.json */,
@ -14471,6 +14474,7 @@
45B74A7B2044AAB600CD42F8 /* chord.aifc in Resources */,
45B74A892044AAB600CD42F8 /* circles-quiet.aifc in Resources */,
45B74A832044AAB600CD42F8 /* circles.aifc in Resources */,
B9A47ACE2D36DA6B0024DD9C /* circular_indeterminate.json in Resources */,
4503F1BE20470A5B00CEE724 /* classic-quiet.aifc in Resources */,
4503F1BF20470A5B00CEE724 /* classic.aifc in Resources */,
45B74A872044AAB600CD42F8 /* complete-quiet.aifc in Resources */,

View File

@ -0,0 +1 @@
{"v":"5.9.0","fr":60,"ip":0,"op":110,"w":52,"h":52,"nm":"Progress indicator - Indeterminate - Circular","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":4,"ty":4,"nm":"1","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[-26]},{"t":81,"s":[0]}],"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[48,48],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.133333340287,0.403921574354,0.960784316063,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":0,"s":[5]},{"i":{"x":[0.679],"y":[0.748]},"o":{"x":[0.43],"y":[0]},"t":40,"s":[0]},{"t":109,"s":[87.471]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.4],"y":[0]},"t":0,"s":[6]},{"i":{"x":[0.614],"y":[0.241]},"o":{"x":[0.16],"y":[0]},"t":40,"s":[75]},{"t":109,"s":[89.606]}],"ix":2},"o":{"a":0,"k":1,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":110,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Rotator","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":109,"s":[397.294]},{"t":323,"s":[1444]}],"ix":10},"p":{"a":0,"k":[26,26,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[],"ip":0,"op":111,"st":0,"bm":0}],"markers":[]}

View File

@ -3,6 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
import SwiftUI
import SignalUI
import SignalServiceKit
@ -11,40 +12,67 @@ import SignalServiceKit
class LinkAndSyncProgressViewModel: ObservableObject {
enum Phase {
case preparing
case syncing
}
@Published private(set) var progress: Float = 0
@Published private(set) var canBeCancelled: Bool = false
@Published var phase: Phase = .preparing
@Published var linkNSyncTask: Task<Void, Never>?
@Published var didTapCancel: Bool = false
@Published var taskProgress: Float = 0
@Published var isIndeterminate = true
@Published var canBeCancelled: Bool = false
@Published var linkNSyncTask: Task<Void, Never>?
#if DEBUG
@Published var progressSourceLabel: String?
#endif
var cancelButtonEnabled: Bool {
linkNSyncTask != nil && canBeCancelled && !didTapCancel
}
var title: String {
switch phase {
case .preparing:
OWSLocalizedString(
"LINK_NEW_DEVICE_SYNC_PROGRESS_TITLE_PREPARING",
comment: "Title for a progress modal indicating the sync progress while it's preparing for upload"
)
case .syncing:
OWSLocalizedString(
"LINK_NEW_DEVICE_SYNC_PROGRESS_TITLE",
comment: "Title for a progress modal indicating the sync progress"
)
}
var progress: Float {
didTapCancel ? 0 : taskProgress
}
func updateProgress(progress: Float, canBeCancelled: Bool) {
self.progress = progress
fileprivate func updateProgress(progress: Float, canBeCancelled: Bool) {
withAnimation(.smooth) {
self.taskProgress = progress
}
self.canBeCancelled = canBeCancelled
}
func updateProgress(progress: OWSProgress) {
// This seems to help with the Lottie bug mentioned below
objectWillChange.send()
let canBeCancelled: Bool
if let label = progress.currentSourceLabel {
canBeCancelled = label != PrimaryLinkNSyncProgressPhase.waitingForLinking.rawValue
} else {
canBeCancelled = false
}
if progress.completedUnitCount > PrimaryLinkNSyncProgressPhase.waitingForLinking.percentOfTotalProgress {
self.isIndeterminate = false
}
updateProgress(
progress: progress.percentComplete,
canBeCancelled: canBeCancelled
)
#if DEBUG
progressSourceLabel = progress.currentSourceLabel
#endif
}
func cancel() {
linkNSyncTask?.cancel()
withAnimation(.smooth(duration: 0.2)) {
taskProgress = 0
}
didTapCancel = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.isIndeterminate = true
}
}
}
// MARK: Hosting Controller
@ -93,6 +121,11 @@ struct LinkAndSyncProgressView: View {
@ObservedObject fileprivate var viewModel: LinkAndSyncProgressViewModel
@State private var indeterminateProgressShouldShow = false
private var loopMode: LottieLoopMode {
viewModel.isIndeterminate ? .loop : .playOnce
}
// If the first portion fills very quickly before the view is visible,
// we still want to animate it from 0.
private var progressToShow: Float {
@ -100,21 +133,77 @@ struct LinkAndSyncProgressView: View {
case .appearing:
0
case .cancelled, .finished, .none:
viewModel.progress
indeterminateProgressShouldShow ? 0 : viewModel.progress
}
}
private var showIndeterminateProgress: Bool {
switch appearanceTransitionState {
case .none, .appearing, .cancelled:
false
case .finished:
viewModel.isIndeterminate || indeterminateProgressShouldShow
}
}
private var title: String {
if viewModel.didTapCancel {
OWSLocalizedString(
"LINK_NEW_DEVICE_SYNC_PROGRESS_TILE_CANCELLING",
comment: "Title for a progress modal that would be indicating the sync progress while it's cancelling that sync"
)
} else if indeterminateProgressShouldShow || appearanceTransitionState != .finished {
OWSLocalizedString(
"LINK_NEW_DEVICE_SYNC_PROGRESS_TITLE_PREPARING",
comment: "Title for a progress modal indicating the sync progress while it's preparing for upload"
)
} else {
OWSLocalizedString(
"LINK_NEW_DEVICE_SYNC_PROGRESS_TITLE",
comment: "Title for a progress modal indicating the sync progress"
)
}
}
var body: some View {
VStack(spacing: 0) {
// TODO: this should become an "indefinite" animation when cancelled
CircleProgressView(progress: progressToShow)
.padding(.top, 14)
.padding(.bottom, 20)
.animation(.linear, value: progressToShow)
ZStack {
CircleProgressView(progress: progressToShow)
.animation(.smooth, value: appearanceTransitionState)
.animation(.smooth, value: indeterminateProgressShouldShow)
Text(viewModel.title)
if showIndeterminateProgress {
LottieView(animation: .named("circular_indeterminate"))
.playing(loopMode: loopMode)
.animationDidFinish { completed in
print("animationDidFinish: \(completed)")
guard completed else { return }
indeterminateProgressShouldShow = false
}
.onAppear {
indeterminateProgressShouldShow = true
}
}
}
.padding(.top, 12)
.padding(.bottom, 20)
.onChange(of: viewModel.isIndeterminate) { isIndeterminate in
guard !isIndeterminate else { return }
// There is a seemingly rng bug where the Lottie
// view doesn't properly respond to the change of
// loopMode, leading to .animationDidFinish never
// being called. The animation is a bit over one
// second, so if it's not done after two seconds,
// force hide it.
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.indeterminateProgressShouldShow = false
}
}
Text(title)
.font(.headline)
.padding(.bottom, 8)
.animation(.none, value: title)
Text(String(
format: OWSLocalizedString(
@ -136,11 +225,17 @@ struct LinkAndSyncProgressView: View {
.padding(.bottom, 36)
Button(CommonStrings.cancelButton) {
viewModel.linkNSyncTask?.cancel()
viewModel.didTapCancel = true
viewModel.cancel()
}
.disabled(!viewModel.cancelButtonEnabled)
.font(.body.weight(.semibold))
#if DEBUG
Text("DEBUG: " + (viewModel.progressSourceLabel ?? "none"))
.padding(.top)
.foregroundStyle(Color.Signal.quaternaryLabel)
.animation(.none, value: viewModel.progressSourceLabel)
#endif
}
.padding(.horizontal, 26)
.padding(.vertical, 28)
@ -163,9 +258,9 @@ struct LinkAndSyncProgressView: View {
.rotation(.degrees(-90))
.stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
.foregroundStyle(Color.Signal.accent)
.animation(.linear, value: progress)
}
.frame(width: 52, height: 52)
.frame(width: 48, height: 48)
.padding(2)
}
}
}
@ -173,30 +268,100 @@ struct LinkAndSyncProgressView: View {
// MARK: Previews
#if DEBUG
@MainActor
@available(iOS 17, *)
#Preview {
SheetPreviewViewController {
let modal = LinkAndSyncProgressModal()
modal.linkNSyncTask = Task {}
private func setupDemoProgress(
modal: LinkAndSyncProgressModal,
slowLinking: Bool
) async throws {
let progress = OWSProgress.createSink { progress in
modal.viewModel.updateProgress(progress: progress)
}
Task { @MainActor in
modal.viewModel.updateProgress(progress: 0.2, canBeCancelled: false)
let loadingPoints = (0..<20)
.map { _ in Float.random(in: (0.2)...1) }
.sorted()
let waitForLinkingProgress = await progress.addSource(
withLabel: PrimaryLinkNSyncProgressPhase.waitingForLinking.rawValue,
unitCount: PrimaryLinkNSyncProgressPhase.waitingForLinking.percentOfTotalProgress
)
let exportingBackupProgress = await progress.addSource(
withLabel: PrimaryLinkNSyncProgressPhase.exportingBackup.rawValue,
unitCount: PrimaryLinkNSyncProgressPhase.exportingBackup.percentOfTotalProgress
)
let uploadingBackupProgress = await progress.addSource(
withLabel: PrimaryLinkNSyncProgressPhase.uploadingBackup.rawValue,
unitCount: PrimaryLinkNSyncProgressPhase.uploadingBackup.percentOfTotalProgress
)
let markUploadedProgress = await progress.addSource(
withLabel: PrimaryLinkNSyncProgressPhase.finishing.rawValue,
unitCount: PrimaryLinkNSyncProgressPhase.finishing.percentOfTotalProgress
)
for point in loadingPoints {
try? await Task.sleep(for: .milliseconds(100))
modal.viewModel.updateProgress(progress: point, canBeCancelled: point >= 0.4)
modal.viewModel.phase = point >= 0.6 ? .syncing : .preparing
}
if slowLinking {
try await Task.sleep(for: .milliseconds(700))
} else {
try await Task.sleep(for: .milliseconds(100))
}
try? await Task.sleep(for: .milliseconds(100))
modal.viewModel.updateProgress(progress: 1, canBeCancelled: true)
waitForLinkingProgress.incrementCompletedUnitCount(by: PrimaryLinkNSyncProgressPhase.waitingForLinking.percentOfTotalProgress)
await modal.completeAndDismiss()
if slowLinking {
try await Task.sleep(for: .milliseconds(700))
} else {
try await Task.sleep(for: .milliseconds(100))
}
func simulateProgress(for source: OWSProgressSource) async throws {
for _ in 0..<(source.totalUnitCount / 2) {
source.incrementCompletedUnitCount(by: 2)
try await Task.sleep(for: .milliseconds(50))
}
source.incrementCompletedUnitCount(by: source.totalUnitCount)
}
try await simulateProgress(for: exportingBackupProgress)
try await simulateProgress(for: uploadingBackupProgress)
try await Task.sleep(for: .milliseconds(500))
try Task.checkCancellation()
markUploadedProgress.incrementCompletedUnitCount(by: PrimaryLinkNSyncProgressPhase.finishing.percentOfTotalProgress)
await modal.completeAndDismiss()
}
@MainActor
@available(iOS 17, *)
func demoTask(
modal: LinkAndSyncProgressModal,
slowLinking: Bool
) -> Task<Void, Never> {
Task {
do {
try await setupDemoProgress(modal: modal, slowLinking: slowLinking)
} catch {
try? await Task.detached {
try await Task.sleep(for: slowLinking ? .seconds(3) : .milliseconds(500))
}.value
modal.dismiss(animated: true)
}
}
}
@available(iOS 17, *)
#Preview("Slow linking") {
SheetPreviewViewController(animateFirstAppearance: true) {
let modal = LinkAndSyncProgressModal()
modal.linkNSyncTask = demoTask(modal: modal, slowLinking: true)
return modal
}
}
@available(iOS 17, *)
#Preview("Fast linking") {
SheetPreviewViewController(animateFirstAppearance: true) {
let modal = LinkAndSyncProgressModal()
modal.linkNSyncTask = demoTask(modal: modal, slowLinking: false)
return modal
}
}

View File

@ -282,30 +282,7 @@ extension LinkedDevicesViewModel: LinkDeviceViewControllerDelegate {
let progress = OWSProgress.createSink { progress in
Task { @MainActor in
let canBeCancelled: Bool
if let label = progress.currentSourceLabel {
canBeCancelled = label != PrimaryLinkNSyncProgressPhase.waitingForLinking.rawValue
} else {
canBeCancelled = false
}
switch progress.currentSourceLabel {
case
PrimaryLinkNSyncProgressPhase.waitingForLinking.rawValue,
PrimaryLinkNSyncProgressPhase.exportingBackup.rawValue:
linkAndSyncProgressModal.viewModel.phase = .preparing
case
PrimaryLinkNSyncProgressPhase.uploadingBackup.rawValue,
PrimaryLinkNSyncProgressPhase.finishing.rawValue:
linkAndSyncProgressModal.viewModel.phase = .syncing
default:
break
}
linkAndSyncProgressModal.viewModel.updateProgress(
progress: progress.percentComplete,
canBeCancelled: canBeCancelled
)
linkAndSyncProgressModal.viewModel.updateProgress(progress: progress)
}
}

View File

@ -4078,6 +4078,9 @@
/* On a progress modal indicating the percent complete the sync process is. Embeds {{ formatted percentage }} */
"LINK_NEW_DEVICE_SYNC_PROGRESS_PERCENT" = "%@ complete";
/* Title for a progress modal that would be indicating the sync progress while it's cancelling that sync */
"LINK_NEW_DEVICE_SYNC_PROGRESS_TILE_CANCELLING" = "Cancelling…";
/* Title for a progress modal indicating the sync progress */
"LINK_NEW_DEVICE_SYNC_PROGRESS_TITLE" = "Syncing Messages…";

View File

@ -57,10 +57,10 @@ public enum PrimaryLinkNSyncProgressPhase: String {
case uploadingBackup
case finishing
var percentOfTotalProgress: UInt64 {
public var percentOfTotalProgress: UInt64 {
return switch self {
case .waitingForLinking: 30
case .exportingBackup: 25
case .waitingForLinking: 5
case .exportingBackup: 50
case .uploadingBackup: 40
case .finishing: 5
}

View File

@ -7,6 +7,7 @@ import UIKit
#if DEBUG
public class SheetPreviewViewController: UIViewController {
private let animateFirstAppearance: Bool
private let presentAction: PresentAction
private enum PresentAction {
@ -25,21 +26,31 @@ public class SheetPreviewViewController: UIViewController {
}
public init(
animateFirstAppearance: Bool = false,
presentSheet: @escaping (
_ viewController: SheetPreviewViewController,
_ animated: Bool
) -> Void
) {
self.animateFirstAppearance = animateFirstAppearance
self.presentAction = .presentSheet(presentSheet)
super.init(nibName: nil, bundle: nil)
}
public init(sheet: @escaping @autoclosure () -> UIViewController) {
public init(
animateFirstAppearance: Bool = false,
sheet: @escaping @autoclosure () -> UIViewController
) {
self.animateFirstAppearance = animateFirstAppearance
self.presentAction = .createSheet(sheet)
super.init(nibName: nil, bundle: nil)
}
public init(createSheet: @escaping () -> UIViewController) {
public init(
animateFirstAppearance: Bool = false,
createSheet: @escaping () -> UIViewController
) {
self.animateFirstAppearance = animateFirstAppearance
self.presentAction = .createSheet(createSheet)
super.init(nibName: nil, bundle: nil)
}
@ -60,7 +71,7 @@ public class SheetPreviewViewController: UIViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.presentAction.present(from: self, animated: false)
self.presentAction.present(from: self, animated: animateFirstAppearance)
}
}
#endif