From 7c02e472d715f12defc688cfbbe429484a1d4342 Mon Sep 17 00:00:00 2001 From: Felix Mau Date: Fri, 29 Apr 2022 18:16:51 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20::=20Reduce=20`precisi?= =?UTF-8?q?on`=20to=20fix=20flaky=20tests=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GradientLoadingBarViewTestCase.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift b/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift index a58e783..17f971d 100644 --- a/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift +++ b/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift @@ -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.99 } // 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)) } } From 3d242373073d5938fb59224adf0e28cc475d72e8 Mon Sep 17 00:00:00 2001 From: Felix Mau Date: Fri, 29 Apr 2022 18:24:09 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20::=20Reduce=20`precisi?= =?UTF-8?q?on`=20to=20fix=20flaky=20tests=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GradientLoadingBarView/GradientLoadingBarViewTestCase.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift b/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift index 17f971d..5c4f4fc 100644 --- a/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift +++ b/Example/ExampleSnapshotTests/GradientLoadingBarView/GradientLoadingBarViewTestCase.swift @@ -28,7 +28,7 @@ final class GradientLoadingBarViewTestCase: XCTestCase { ].map(Color.init) /// The percentage of pixels that must match. - static let precision: Float = 0.99 + static let precision: Float = 0.98 } // MARK: - Test cases From 54a413d9868e75838af216d534c07fcfed449b3d Mon Sep 17 00:00:00 2001 From: Felix Mau Date: Fri, 29 Apr 2022 18:40:10 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20::=20Fix=20`xcodebuild`=20er?= =?UTF-8?q?rors=20when=20running=20`pod=20lib=20lint`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GradientLoadingBarView+ViewModel.swift | 104 ++++++++------- .../GradientLoadingBarView.swift | 124 ++++++++++-------- 2 files changed, 124 insertions(+), 104 deletions(-) diff --git a/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView+ViewModel.swift b/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView+ViewModel.swift index 6c6330e..f74264e 100644 --- a/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView+ViewModel.swift +++ b/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView+ViewModel.swift @@ -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 diff --git a/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView.swift b/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView.swift index f663190..c68eef5 100644 --- a/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView.swift +++ b/GradientLoadingBar/Feature/GradientLoadingBarView/GradientLoadingBarView.swift @@ -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 From c0f27a3e48cdbd2aeb2a88ed9b9a8cec61403b5f Mon Sep 17 00:00:00 2001 From: Felix Mau Date: Fri, 29 Apr 2022 18:43:44 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20::=20Remove=20outdated?= =?UTF-8?q?=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GradientLoadingBarControllerTestCase.swift | 7 ------- .../NotchGradientLoadingBarControllerTestCase.swift | 7 ------- 2 files changed, 14 deletions(-) diff --git a/Example/ExampleSnapshotTests/GradientLoadingBarController/GradientLoadingBarControllerTestCase.swift b/Example/ExampleSnapshotTests/GradientLoadingBarController/GradientLoadingBarControllerTestCase.swift index dd0a0a2..b67cad0 100644 --- a/Example/ExampleSnapshotTests/GradientLoadingBarController/GradientLoadingBarControllerTestCase.swift +++ b/Example/ExampleSnapshotTests/GradientLoadingBarController/GradientLoadingBarControllerTestCase.swift @@ -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)) } diff --git a/Example/ExampleSnapshotTests/GradientLoadingBarController/NotchGradientLoadingBarControllerTestCase.swift b/Example/ExampleSnapshotTests/GradientLoadingBarController/NotchGradientLoadingBarControllerTestCase.swift index 49c744e..7b74cc9 100644 --- a/Example/ExampleSnapshotTests/GradientLoadingBarController/NotchGradientLoadingBarControllerTestCase.swift +++ b/Example/ExampleSnapshotTests/GradientLoadingBarController/NotchGradientLoadingBarControllerTestCase.swift @@ -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)) }