mirror of
https://github.com/ProtonMail/ios-mail.git
synced 2026-06-14 09:54:45 +00:00
165 lines
5.5 KiB
Swift
165 lines
5.5 KiB
Swift
// Copyright (c) 2025 Proton Technologies AG
|
|
//
|
|
// This file is part of Proton Mail.
|
|
//
|
|
// Proton Mail is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Proton Mail is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
|
|
|
import InboxDesignSystem
|
|
import SwiftUI
|
|
|
|
/// A view that displays a progress bar with a cycling, animated gradient.
|
|
/// The animation continuously moves from left to right, creating an infinite loading effect.
|
|
struct CyclingProgressBar: View {
|
|
private struct ViewState {
|
|
let barHeight: CGFloat = 2
|
|
let primaryColor = DS.Color.Loader.success
|
|
let edgeColor = DS.Color.Loader.success.opacity(0)
|
|
}
|
|
|
|
private let configuration: LoadingBarConfiguration
|
|
private let onCycleCompleted: (() -> Void)
|
|
/// Only for snapshot testing purposes to disable animation and allow manual control of animation phase
|
|
private let isAnimationEnabled: Bool
|
|
private let viewState = ViewState()
|
|
|
|
@State private var animationPhase: CGFloat
|
|
@State private var isAnimating: Bool = false
|
|
|
|
init(configuration: LoadingBarConfiguration, onCycleCompleted: @escaping (() -> Void)) {
|
|
self.configuration = configuration
|
|
self.onCycleCompleted = onCycleCompleted
|
|
self.isAnimationEnabled = true
|
|
_animationPhase = State(initialValue: 0)
|
|
}
|
|
|
|
/// Only for snapshot testing purposes to disable animation and control the animation phase
|
|
init(animationPhase: CGFloat) {
|
|
self.configuration = .init()
|
|
self.onCycleCompleted = {}
|
|
self.isAnimationEnabled = false
|
|
_animationPhase = State(initialValue: animationPhase)
|
|
}
|
|
|
|
// MARK: - View
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let gradient = LinearGradient(
|
|
gradient: Gradient(colors: [viewState.edgeColor, viewState.primaryColor, viewState.edgeColor]),
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
|
|
let totalWidth = geometry.size.width
|
|
let barWidth = totalWidth * 0.58
|
|
let xOffset = animationPhase * (totalWidth + barWidth) - barWidth
|
|
|
|
Rectangle()
|
|
.fill(gradient)
|
|
.frame(width: barWidth)
|
|
.offset(x: xOffset)
|
|
}
|
|
.frame(height: viewState.barHeight)
|
|
.onAppear { startAnimatingIfNeeded() }
|
|
.onDisappear { stopAnimating() }
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func startAnimatingIfNeeded() {
|
|
guard isAnimationEnabled, !isAnimating else { return }
|
|
|
|
isAnimating = true
|
|
|
|
startAnimationCycle()
|
|
}
|
|
|
|
private func stopAnimating() {
|
|
isAnimating = false
|
|
}
|
|
|
|
private func startAnimationCycle() {
|
|
guard isAnimating else { return }
|
|
|
|
animationPhase = 0
|
|
|
|
withAnimation(.linear(duration: configuration.cycleDuration)) {
|
|
animationPhase = 1.0
|
|
} completion: {
|
|
guard isAnimating else { return }
|
|
onCycleCompleted()
|
|
continueAnimationCycle()
|
|
}
|
|
}
|
|
|
|
/// When using `withAnimation`, if there is no visible content to animate,
|
|
/// SwiftUI may trigger the completion closure immediately, without waiting
|
|
/// for the animation duration.
|
|
///
|
|
/// In this case, the completion handler starts the same animation cycle again,
|
|
/// which results in an endless loop of immediate animation completions and
|
|
/// restarts.
|
|
///
|
|
/// This behavior occurs only on iOS 17 and only when another view is presented
|
|
/// over the mailbox, making the cycling progress bar not visible. Since the
|
|
/// progress bar is not rendered in this state, the animation should not be
|
|
/// triggered at all, but this is how iOS 17 behaves.
|
|
///
|
|
/// The issue is resolved by dispatching the next animation cycle asynchronously
|
|
/// using `DispatchQueue.main.async`, which ensures that the animation restart
|
|
/// does not occur within the same animation transaction, preventing the
|
|
/// immediate completion loop.
|
|
private func continueAnimationCycle() {
|
|
if #available(iOS 18, *) {
|
|
startAnimationCycle()
|
|
} else {
|
|
DispatchQueue.main.async {
|
|
startAnimationCycle()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CyclingProgressBar_Preview: View {
|
|
@State private var visible: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .center, spacing: 20) {
|
|
Text("Cycling progress bar (\(visible ? "visible" : "hidden"))")
|
|
.font(.headline)
|
|
.padding(.top, 60)
|
|
if visible {
|
|
CyclingProgressBar(configuration: .init(), onCycleCompleted: {})
|
|
}
|
|
|
|
Spacer()
|
|
Button {
|
|
visible.toggle()
|
|
} label: {
|
|
Text(visible ? "Hide" : "Show")
|
|
.font(.headline)
|
|
.padding(20)
|
|
.frame(width: 150)
|
|
}
|
|
.background(Color.gray.opacity(0.2))
|
|
.roundedRectangleStyle()
|
|
}
|
|
.padding([.horizontal], 0)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CyclingProgressBar_Preview()
|
|
}
|