From 85a61b4433ffcdd55888979e7aa888e5f3863e3d Mon Sep 17 00:00:00 2001 From: Felix Mau Date: Mon, 21 Mar 2022 16:40:31 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20::=20Fix=20glitch=20in=20animati?= =?UTF-8?q?on=20due=20to=20incorrect=20gradient=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwiftUI/GradientLoadingBarView.swift | 83 ++++++++++++++----- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/GradientLoadingBar/SwiftUI/GradientLoadingBarView.swift b/GradientLoadingBar/SwiftUI/GradientLoadingBarView.swift index 37f12c9..e09dc57 100644 --- a/GradientLoadingBar/SwiftUI/GradientLoadingBarView.swift +++ b/GradientLoadingBar/SwiftUI/GradientLoadingBarView.swift @@ -8,7 +8,8 @@ import SwiftUI -@available(iOS 13.0, *) +// For some reason the animation looks broken on iOS versions <= 15.0. +@available(iOS 15.0, *) public struct GradientLoadingBarView: View { // MARK: - Config @@ -23,7 +24,22 @@ public struct GradientLoadingBarView: View { // MARK: - Private properties private let gradientColors: [Color] - private let progressAnimation: Animation + private let progressDuration: TimeInterval + + @State + private var size: CGSize = .zero { + didSet { + // This will stop the ongoing animation. + // Source: https://stackoverflow.com/a/59150940 + withAnimation(.linear(duration: 0)) { + offset = size.width * -1 + } + + withAnimation(.linear(duration: progressDuration).repeatForever(autoreverses: false)) { + offset = size.width * 1 + } + } + } @State private var offset: CGFloat = 0 @@ -32,38 +48,61 @@ public struct GradientLoadingBarView: View { public init(gradientColors: [Color] = Config.gradientColors, progressDuration: TimeInterval = Config.progressDuration) { + // 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] - var reversedGradientColors = Array(gradientColors.reversed()) - reversedGradientColors.removeFirst() - reversedGradientColors.removeLast() + let reversedGradientColors = gradientColors + .reversed() + .dropFirst() + .dropLast() self.gradientColors = gradientColors + reversedGradientColors + gradientColors - - progressAnimation = .linear(duration: progressDuration).repeatForever(autoreverses: false) + self.progressDuration = progressDuration } // MARK: - Render public var body: some View { - GeometryReader { proxy in - LinearGradient(colors: gradientColors, startPoint: .leading, endPoint: .trailing) - // To fit `gradientColors + reversedGradientColors + gradientColors` in our view, - // we have to apply three times the width of our parent view. - .frame(width: proxy.size.width * 3) - .offset(x: offset, y: 0) - .onAppear { - // We want to animate from left to right. - // Therefore we start with the negative offset, and afterwards animate to an offset of zero. - offset = proxy.size.width * -2 + 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) { + size = $0 + } + // Using an `overlay` here makes sure that the parent view won't change it's frame. + .overlay(// + LinearGradient(colors: gradientColors, startPoint: .leading, endPoint: .trailing) + // To fit `gradientColors + reversedGradientColors + gradientColors` in our view, + // we have to apply three times the width of our parent view. + .frame(width: size.width * 3) + .offset(x: offset, y: 0)) + } +} - withAnimation(progressAnimation) { - offset = 0 - } - } - } +// MARK: - Helper + +private struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + + 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) + } + ) } }