Merge branch 'feature/add-support-for-swiftui' into feature/adapt-uikit-animation-to-swiftui-variant

This commit is contained in:
Felix Mau
2022-04-29 18:49:04 +01:00
5 changed files with 129 additions and 120 deletions
@@ -30,14 +30,7 @@ final class GradientLoadingBarControllerTestCase: XCTestCase {
// When
gradientLoadingBarController.fadeIn(duration: 0)
let expectation = expectation(description: "Wait for view to appear.")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
expectation.fulfill()
}
// Then
wait(for: [expectation], timeout: 1)
assertSnapshot(matching: rootViewController,
as: .image(drawHierarchyInKeyWindow: true, precision: Config.precision))
}
@@ -31,14 +31,7 @@ final class NotchGradientLoadingBarControllerTestCase: XCTestCase {
// When
notchGradientLoadingBarController.fadeIn(duration: 0)
let expectation = expectation(description: "Wait for view to appear.")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
expectation.fulfill()
}
// Then
wait(for: [expectation], timeout: 1)
assertSnapshot(matching: rootViewController,
as: .image(drawHierarchyInKeyWindow: true, precision: Config.precision))
}
@@ -26,6 +26,9 @@ final class GradientLoadingBarViewTestCase: XCTestCase {
static let gradientColors = [
#colorLiteral(red: 0.9490196078, green: 0.3215686275, blue: 0.431372549, alpha: 1), #colorLiteral(red: 0.9450980392, green: 0.4784313725, blue: 0.5921568627, alpha: 1), #colorLiteral(red: 0.9529411765, green: 0.737254902, blue: 0.7843137255, alpha: 1), #colorLiteral(red: 0.4274509804, green: 0.8666666667, blue: 0.9490196078, alpha: 1), #colorLiteral(red: 0.7568627451, green: 0.9411764706, blue: 0.9568627451, alpha: 1),
].map(Color.init)
/// The percentage of pixels that must match.
static let precision: Float = 0.98
}
// MARK: - Test cases
@@ -36,7 +39,7 @@ final class GradientLoadingBarViewTestCase: XCTestCase {
.frame(width: Config.frame.width, height: Config.frame.height)
// Then
assertSnapshot(matching: gradientLoadingBarView, as: .image)
assertSnapshot(matching: gradientLoadingBarView, as: .image(precision: Config.precision))
}
func test_gradientLoadingBarView_shouldContainCorrectCustomColors() {
@@ -45,6 +48,6 @@ final class GradientLoadingBarViewTestCase: XCTestCase {
.frame(width: Config.frame.width, height: Config.frame.height)
// Then
assertSnapshot(matching: gradientLoadingBarView, as: .image)
assertSnapshot(matching: gradientLoadingBarView, as: .image(precision: Config.precision))
}
}
@@ -9,67 +9,73 @@
import Combine
import SwiftUI
@available(iOS 15.0, *)
extension GradientLoadingBarView {
/// This view model contains all logic related to the `GradientLoadingBarView`
/// and the corresponding progress animation.
final class ViewModel: ObservableObject {
// See documentation of `GradientLoadingBarView` for further details
// on this compile condition.
#if arch(arm64) || arch(x86_64)
// MARK: - Public properties
@available(iOS 15.0, *)
extension GradientLoadingBarView {
/// This view model contains all logic related to the `GradientLoadingBarView`
/// and the corresponding progress animation.
final class ViewModel: ObservableObject {
/// The gradient colors used for the progress animation (including the reversed colors).
let gradientColors: [Color]
// MARK: - Public properties
/// The horizontal offset of the `LinearGradient` used to simulate the progress animation.
@Published
private(set) var horizontalOffset: CGFloat = 0
/// The gradient colors used for the progress animation (including the reversed colors).
let gradientColors: [Color]
/// The current size of the `GradientLoadingBarView`.
@Published
var size: CGSize = .zero {
didSet {
// This will stop any ongoing animation.
// Source: https://stackoverflow.com/a/59150940
withAnimation(.linear(duration: 0)) {
horizontalOffset = -size.width
}
/// The horizontal offset of the `LinearGradient` used to simulate the progress animation.
@Published
private(set) var horizontalOffset: CGFloat = 0
let progressAnimation: Animation = .linear(duration: progressDuration).repeatForever(autoreverses: false)
withAnimation(progressAnimation) {
horizontalOffset = size.width
/// The current size of the `GradientLoadingBarView`.
@Published
var size: CGSize = .zero {
didSet {
// This will stop any ongoing animation.
// Source: https://stackoverflow.com/a/59150940
withAnimation(.linear(duration: 0)) {
horizontalOffset = -size.width
}
let progressAnimation: Animation = .linear(duration: progressDuration).repeatForever(autoreverses: false)
withAnimation(progressAnimation) {
horizontalOffset = size.width
}
}
}
}
/// The width of the `LinearGradient`.
var gradientWidth: CGFloat {
// To fit `gradientColors + reversedGradientColors + gradientColors` in our view,
// we have to apply three times the width of our parent view.
size.width * 3
}
/// The width of the `LinearGradient`.
var gradientWidth: CGFloat {
// To fit `gradientColors + reversedGradientColors + gradientColors` in our view,
// we have to apply three times the width of our parent view.
size.width * 3
}
// MARK: - Private properties
// MARK: - Private properties
private var progressDuration: TimeInterval
private var progressDuration: TimeInterval
// MARK: - Instance Lifecycle
// MARK: - Instance Lifecycle
init(gradientColors: [Color], progressDuration: TimeInterval) {
// Simulate infinite animation - Therefore we'll reverse the colors and remove the first and last item
// to prevent duplicate values at the "inner edges" destroying the infinite look.
//
// E.g. for array of [.red, .yellow, .green]
// we will create [.red, .yellow, .green, .yellow, .red, .yellow, .green]
//
// E.g. for array of [.red, .yellow, .green, .blue]
// we will create [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue]
let reversedGradientColors = gradientColors
.reversed()
.dropFirst()
.dropLast()
init(gradientColors: [Color], progressDuration: TimeInterval) {
// Simulate infinite animation - Therefore we'll reverse the colors and remove the first and last item
// to prevent duplicate values at the "inner edges" destroying the infinite look.
//
// E.g. for array of [.red, .yellow, .green]
// we will create [.red, .yellow, .green, .yellow, .red, .yellow, .green]
//
// E.g. for array of [.red, .yellow, .green, .blue]
// we will create [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue]
let reversedGradientColors = gradientColors
.reversed()
.dropFirst()
.dropLast()
self.gradientColors = gradientColors + reversedGradientColors + gradientColors
self.progressDuration = progressDuration
self.gradientColors = gradientColors + reversedGradientColors + gradientColors
self.progressDuration = progressDuration
}
}
}
}
#endif
@@ -8,74 +8,88 @@
import SwiftUI
// For some reason the animation looks broken on iOS versions <= 15.0.
@available(iOS 15.0, *)
public struct GradientLoadingBarView: View {
// Workaround to fix `xcodebuild` errors when running `pod lib lint`, like e.g.:
//
// - `xcodebuild`: error: cannot find type 'Color' in scope
// - `xcodebuild`: error: cannot find type 'View' in scope
//
// > This failure occurs because builds with a deployment target earlier than iOS 11 will also build for the armv7 architecture,
// > and there is no armv7 swiftmodule for SwiftUI in the iOS SDK because the OS version in which it was first introduced (iOS 13)
// > does not support armv7 anymore.
//
// Source: https://stackoverflow.com/a/61954608
#if arch(arm64) || arch(x86_64)
// MARK: - Config
// For some reason the animation looks broken on iOS versions <= 15.0.
@available(iOS 15.0, *)
public struct GradientLoadingBarView: View {
public enum Config {
/// The default color palette for the gradient colors.
public static let gradientColors = UIColor.GradientLoadingBar.gradientColors.map(Color.init)
// MARK: - Config
/// The default duration for the progress animation, measured in seconds.
public static let progressDuration = TimeInterval.GradientLoadingBar.progressDuration
}
public enum Config {
/// The default color palette for the gradient colors.
public static let gradientColors = UIColor.GradientLoadingBar.gradientColors.map(Color.init)
// MARK: - Private properties
/// The default duration for the progress animation, measured in seconds.
public static let progressDuration = TimeInterval.GradientLoadingBar.progressDuration
}
@StateObject
private var viewModel: ViewModel
// MARK: - Private properties
// MARK: - Instance Lifecycle
@StateObject
private var viewModel: ViewModel
public init(gradientColors: [Color] = Config.gradientColors,
progressDuration: TimeInterval = Config.progressDuration) {
// Even though the docs mention that "You don’t call this initializer directly", this seems to be the correct way to set-up a
// `StateObject` with parameters according to "Lessons from the SwiftUI Digital Lounge".
// https://swiftui-lab.com/random-lessons/#data-10
_viewModel = StateObject(
wrappedValue: ViewModel(gradientColors: gradientColors, progressDuration: progressDuration)
)
}
// MARK: - Instance Lifecycle
// MARK: - Render
public var body: some View {
Color.clear
// We explicitly have to use a `PreferenceKey` here and store the size on a property in order to restart the animation whenever
// the size changes. Using a `GeometryReader` together with the `onAppear(_:)` view-modifier doesn't reflect any size changes.
.modifier(SizeModifier())
.onPreferenceChange(SizePreferenceKey.self) {
viewModel.size = $0
}
// Using an `overlay` here makes sure that the parent view won't change it's frame.
.overlay(
LinearGradient(colors: viewModel.gradientColors, startPoint: .leading, endPoint: .trailing)
.frame(width: viewModel.gradientWidth)
.offset(x: viewModel.horizontalOffset, y: 0)
public init(gradientColors: [Color] = Config.gradientColors,
progressDuration: TimeInterval = Config.progressDuration) {
// Even though the docs mention that "You don’t call this initializer directly", this seems to be the correct way to set-up a
// `StateObject` with parameters according to "Lessons from the SwiftUI Digital Lounge".
// https://swiftui-lab.com/random-lessons/#data-10
_viewModel = StateObject(
wrappedValue: ViewModel(gradientColors: gradientColors, progressDuration: progressDuration)
)
}
// MARK: - Render
public var body: some View {
Color.clear
// We explicitly have to use a `PreferenceKey` here and store the size on a property in order to restart the animation whenever
// the size changes. Using a `GeometryReader` together with the `onAppear(_:)` view-modifier doesn't reflect any size changes.
.modifier(SizeModifier())
.onPreferenceChange(SizePreferenceKey.self) {
viewModel.size = $0
}
// Using an `overlay` here makes sure that the parent view won't change it's frame.
.overlay(
LinearGradient(colors: viewModel.gradientColors, startPoint: .leading, endPoint: .trailing)
.frame(width: viewModel.gradientWidth)
.offset(x: viewModel.horizontalOffset, y: 0)
)
}
}
}
// MARK: - Helper
// MARK: - Helper
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
}
@available(iOS 15.0, *)
private struct SizeModifier: ViewModifier {
func body(content: Content) -> some View {
content.background(
GeometryReader { geometry in
Color.clear.preference(key: SizePreferenceKey.self,
value: geometry.size)
}
)
@available(iOS 15.0, *)
private struct SizeModifier: ViewModifier {
func body(content: Content) -> some View {
content.background(
GeometryReader { geometry in
Color.clear.preference(key: SizePreferenceKey.self,
value: geometry.size)
}
)
}
}
}
#endif