diff --git a/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift b/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift index 8bfd8b4..7e1d900 100644 --- a/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift +++ b/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift @@ -7,8 +7,8 @@ // import XCTest +import LightweightObservable -@testable import LightweightObservable @testable import GradientLoadingBar // swiftlint:disable:next type_name @@ -38,35 +38,21 @@ class GradientActivityIndicatorViewModelTestCase: XCTestCase { // MARK: - Test initializer - func testInitializerShouldSetIsAnimatingProgressToTrue() { - XCTAssertTrue(viewModel.isAnimatingProgress.value) + func testInitializerShouldSetGradientLayerColorsToCorrectValue() { + let expectedGradientLayerColors = makeGradientLayerColors() + XCTAssertEqual(viewModel.gradientLayerColors.value, expectedGradientLayerColors) } - func testInitializerShouldSetGradientLayerFrameToZero() { - XCTAssertEqual(viewModel.gradientLayerFrame.value, .zero) - } + func testInitializerShouldSetGradientLayerLocationsToCorrectValue() { + let receivedGradientLayerLocations = viewModel.gradientLayerLocations.value + let expectedGradientLayerLocations = makeGradientLocationAnimationMatrixInitialRow() - func testInitializerShouldSetGradientLayerAnimationFromValueToZero() { - XCTAssertEqual(viewModel.gradientLayerAnimationFromValue.value, 0.0) - } + // Unfortunately there is no easier way comparing an array of type `NSNumber` / `Double` with a given accuracy. + XCTAssertEqual(receivedGradientLayerLocations.count, expectedGradientLayerLocations.count) - func testInitializerShouldSetGradientLayerAnimationDurationToValueFromProgressAnimationDuration() { - XCTAssertEqual(viewModel.gradientLayerAnimationDuration.value, viewModel.progressAnimationDuration) - } - - func testInitializerShouldSetGradientLayerColorsBasedOnCurrentGradientColors() { - let extectedGradientLayerColors = - makeGradientLayerColors(from: viewModel.gradientColors) - - XCTAssertEqual(viewModel.gradientLayerColors.value, extectedGradientLayerColors) - } - - func testInitializerShouldSetIsHiddenToFalse() { - XCTAssertFalse(viewModel.isHidden) - } - - func testInitializerShouldSetBoundsToZero() { - XCTAssertEqual(viewModel.bounds, .zero) + for (receivedLocation, expectedLocation) in zip(receivedGradientLayerLocations, expectedGradientLayerLocations) { + XCTAssertEqual(receivedLocation.doubleValue, expectedLocation.doubleValue, accuracy: Double.ulpOfOne) + } } func testInitializerShouldSetGradientColorsToStaticConfigurationProperty() { @@ -77,143 +63,173 @@ class GradientActivityIndicatorViewModelTestCase: XCTestCase { XCTAssertEqual(viewModel.progressAnimationDuration, TimeInterval.GradientLoadingBar.progressDuration) } - // MARK: - Test setting property `isHidden` + func testInitializerShouldSetIsHiddenToFalse() { + XCTAssertFalse(viewModel.isHidden) + } - func testSettingIsHiddenToTrueShouldUpdateIsAnimatingProgressToFalse() { + // MARK: - Test property `gradientColors` + + func testSettingGradientColorsShouldUpdateGradientLayerColorsObservable() { + // Given + let gradientColors: [UIColor] = [.red, .yellow, .green] + + // When + viewModel.gradientColors = gradientColors + + // Then + // + // `gradientColors = [.red, .yellow, .green]` + // `gradientLayerColors = [.red, .yellow, .green, .yellow, .red, .yellow, .green]` + // + let expectedGradientColors: [UIColor] = [.red, .yellow, .green, .yellow, .red, .yellow, .green] + let expectedGradientLayerColors = expectedGradientColors.map { $0.cgColor } + + XCTAssertEqual(viewModel.gradientLayerColors.value, expectedGradientLayerColors) + } + + func testSettingGradientColorsShouldUpdateGradientLayerLocationsObservable() { + // Given + let gradientColors: [UIColor] = [.red, .yellow, .green] + + // When + viewModel.gradientColors = gradientColors + + // Then + // + // `gradientColors = [.red, .yellow, .green]` + // `gradientLayerColors = [.red, .yellow, .green, .yellow, .red, .yellow, .green]` + // + // gradientLayerColors | .red | .yellow | .green | .green | .yellow | .red | .yellow | .green + // initialLocations | 0 | 0 | 0 | 0 | 0 | 0 | 0.5 | 1 + // + let expectedGradientLocations = [0, 0, 0, 0, 0, 0.5, 1] + let expectedGradientLayerLocations = expectedGradientLocations.map { NSNumber(value: $0) } + + XCTAssertEqual(viewModel.gradientLayerLocations.value, expectedGradientLayerLocations) + } + + // MARK: - Test property `isHidden` + + func testSettingIsHiddenToTrueShouldInformDelegateToStopAnimatingLocations() { // When viewModel.isHidden = true // Then - XCTAssertFalse(viewModel.isAnimatingProgress.value) + XCTAssertEqual(delegateMock.invokedMethod, .stopAnimatingLocations) } - func testSettingIsHiddenToFalseShouldUpdateIsAnimatingProgressToTrue() { + func testSettingIsHiddenToFalseShouldInformDelegateToStartAnimatingLocations() { + // Given + + // In order to simplify the matrix, we're gonna update the `gradientColors` first. + let gradientColors: [UIColor] = [.red, .yellow, .green] + viewModel.gradientColors = gradientColors + + XCTAssertNil(delegateMock.invokedMethod, + "Precondition failed – Expected delegate not to be informed at this point!") + // When viewModel.isHidden = false // Then - XCTAssertTrue(viewModel.isAnimatingProgress.value) - } + // + // `gradientColors = [.red, .yellow, .green]` + // `gradientLayerColors = [.red, .yellow, .green, .yellow, .red, .yellow, .green]` + // + // i | .red | .yellow | .green | .yellow | .red | .yellow | .green + // 0 | 0 | 0 | 0 | 0 | 0 | 0.5 | 1 + // 1 | 0 | 0 | 0 | 0 | 0.5 | 1 | 1 + // 2 | 0 | 0 | 0 | 0.5 | 1 | 1 | 1 + // 3 | 0 | 0 | 0.5 | 1 | 1 | 1 | 1 + // 4 | 0 | 0.5 | 1 | 1 | 1 | 1 | 1 + // + let gradientLocationsMatrix = [ + [0, 0, 0, 0, 0, 0.5, 1], + [0, 0, 0, 0, 0.5, 1, 1], + [0, 0, 0, 0.5, 1, 1, 1], + [0, 0, 0.5, 1, 1, 1, 1], + [0, 0.5, 1, 1, 1, 1, 1] + ] - // MARK: - Test setting property `bounds` + let expectedValues = gradientLocationsMatrix.map { + $0.map { NSNumber(value: $0) } + } - func testSettingBoundsShouldUpdateGradientLayerFrame() { - // Given - let bounds = CGRect(x: 1.0, y: 2.0, width: 3.0, height: 4.0) - - // When - viewModel.bounds = bounds - - // Then - let expectedFrame = CGRect(x: 0.0, y: 0.0, width: bounds.width * 3, height: bounds.height) - XCTAssertEqual(viewModel.gradientLayerFrame.value, expectedFrame) - } - - func testSettingBoundsShouldUpdateGradientLayerAnimationFromValue() { - // Given - let bounds = CGRect(x: 1.0, y: 2.0, width: 3.0, height: 4.0) - - // When - viewModel.bounds = bounds - - // Then - let expectedAnimationFromValue = -2 * bounds.size.width - XCTAssertEqual(viewModel.gradientLayerAnimationFromValue.value, expectedAnimationFromValue) - } - - func testSettingBoundsShouldInformDelegateToRestartAnimation() { - // When - viewModel.bounds = CGRect(x: 1.0, y: 2.0, width: 3.0, height: 4.0) - - // Then - XCTAssertTrue(delegateMock.didCallRestartAnimation) - } - - func testSettingBoundsShouldNotInformDelegateToRestartAnimationAsTheViewIsCurrentlyHidden() { - // Given - viewModel.isHidden = true - - // When - viewModel.bounds = CGRect(x: 1.0, y: 2.0, width: 3.0, height: 4.0) - - // Then - XCTAssertFalse(delegateMock.didCallRestartAnimation) - } - - // MARK: - Test setting property `gradientColors` - - func testSettingGradientColorsShouldUpdateGradientLayerColors() { - // Given - let colors: [UIColor] = [.red, .yellow, .green] - - // When - viewModel.gradientColors = colors - - // Then - let extectedGradientLayerColors = - makeGradientLayerColors(from: colors) - - XCTAssertEqual(viewModel.gradientLayerColors.value, extectedGradientLayerColors) - } - - // MARK: - Test setting property `progressAnimationDuration` - - func testSettingProgressAnimationDurationShouldUpdateGradientLayerAnimationDuration() { - // Given - let progressAnimationDuration = 1.23 - - // When - viewModel.progressAnimationDuration = progressAnimationDuration - - // Then - XCTAssertEqual(viewModel.gradientLayerAnimationDuration.value, progressAnimationDuration) - } - - func testSettingProgressAnimationDurationShouldInformDelegateToRestartAnimation() { - // When - viewModel.progressAnimationDuration = 1.23 - - // Then - XCTAssertTrue(delegateMock.didCallRestartAnimation) - } - - func testSettingProgressAnimationDurationShouldNotInformDelegateToRestartAnimationAsTheViewIsCurrentlyHidden() { - // Given - viewModel.isHidden = true - - // When - viewModel.progressAnimationDuration = 1.23 - - // Then - XCTAssertFalse(delegateMock.didCallRestartAnimation) + XCTAssertEqual(delegateMock.invokedMethod, .startAnimatingLocations(values: expectedValues, + duration: viewModel.progressAnimationDuration)) } } // MARK: - Helpers extension GradientActivityIndicatorViewModelTestCase { - private func makeGradientLayerColors(from gradientColors: [UIColor]) -> [CGColor] { - let reversedColors = gradientColors - .reversed() - .dropFirst() - .dropLast() + private func makeGradientLayerColors() -> [CGColor] { + let gradientColors = [ + #colorLiteral(red: 0.2980392157, green: 0.8509803922, blue: 0.3921568627, alpha: 1), #colorLiteral(red: 0.3529411765, green: 0.7843137255, blue: 0.9803921569, alpha: 1), #colorLiteral(red: 0, green: 0.4784313725, blue: 1, alpha: 1), #colorLiteral(red: 0.2039215686, green: 0.6666666667, blue: 0.862745098, alpha: 1), #colorLiteral(red: 0.3450980392, green: 0.337254902, blue: 0.8392156863, alpha: 1), #colorLiteral(red: 1, green: 0.1764705882, blue: 0.3333333333, alpha: 1) + ] - let infiniteGradientColors = gradientColors + reversedColors + gradientColors + XCTAssertEqual(gradientColors, UIColor.GradientLoadingBar.gradientColors, + "Precondition failed – The given gradient colors do not match the current color constant!") + + // swiftlint:disable:next identifier_name + let reversedGradientColorsWithoutFirstAndLastValue = [ + #colorLiteral(red: 0.3450980392, green: 0.337254902, blue: 0.8392156863, alpha: 1), #colorLiteral(red: 0.2039215686, green: 0.6666666667, blue: 0.862745098, alpha: 1), #colorLiteral(red: 0, green: 0.4784313725, blue: 1, alpha: 1), #colorLiteral(red: 0.3529411765, green: 0.7843137255, blue: 0.9803921569, alpha: 1) + ] + + let infiniteGradientColors = gradientColors + reversedGradientColorsWithoutFirstAndLastValue + gradientColors return infiniteGradientColors.map { $0.cgColor } } + + private func makeGradientLocations() -> [NSNumber] { + let gradientLocations = [0, 0.2, 0.4, 0.6, 0.8, 1] + + XCTAssertEqual(gradientLocations.count, UIColor.GradientLoadingBar.gradientColors.count, + "Precondition failed – The given gradient locations do not match for the current color constant!") + + return gradientLocations.map { NSNumber(value: $0) } + } + + private func makeGradientLocationAnimationMatrixInitialRow() -> [NSNumber] { + let gradientLocations = [0, 0.2, 0.4, 0.6, 0.8, 1] + + XCTAssertEqual(gradientLocations.count, UIColor.GradientLoadingBar.gradientColors.count, + "Precondition failed – The given gradient locations do not match for the current color constant!") + + // The current constant has 6 value and therefore we expect 16 gradient layer colors in total. + // Ergo we have to fill the first 10 values with "0", before adding the `gradientLocations`. + // + // .green | .malibu | .azure | .curious | .violet | .red | .violet | .curious | .azure | .malibu | .green | .malibu | .azure | .curious | .violet | .red + // 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.2 | 0.4 | 0.6 | 0.8 | 1 + // + // swiftlint:disable:next identifier_name + let gradientLocationAnimationMatrixInitialRow = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + gradientLocations + + return gradientLocationAnimationMatrixInitialRow.map { NSNumber(value: $0) } + } } // MARK: - Mocks // swiftlint:disable:next type_name -class GradientActivityIndicatorViewModelDelegateMock: GradientActivityIndicatorViewModelDelegate { +private class GradientActivityIndicatorViewModelDelegateMock: GradientActivityIndicatorViewModelDelegate { + // MARK: - Types + + enum DelegateMethod: Equatable { + case startAnimatingLocations(values: GradientLocationAnimationMatrix, duration: TimeInterval) + case stopAnimatingLocations + } + // MARK: - Public properties - private(set) var didCallRestartAnimation = false + private(set) var invokedMethod: DelegateMethod? // MARK: - Public methods - func restartAnimation() { - didCallRestartAnimation = true + func startAnimatingLocations(values: GradientLocationAnimationMatrix, duration: TimeInterval) { + invokedMethod = .startAnimatingLocations(values: values, duration: duration) + } + + func stopAnimatingLocations() { + invokedMethod = .stopAnimatingLocations } } diff --git a/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift b/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift index 93ee995..1482f57 100644 --- a/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift +++ b/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift @@ -9,77 +9,82 @@ import Foundation import LightweightObservable +// MARK: - Types + +/// Array containing an array of locations, that are used to position the gradient colors during the animation: +/// - The outer array defines each animation step. +/// - The inner array defines the location of each gradient-color during this animation step. +typealias GradientLocationAnimationMatrix = [[NSNumber]] + /// Classes implementing this delegate protocol will get notified about animation changes. /// -/// - Note: Unfortunatly `LightweightObservable` doesn't support signals (yet), therefore we fallback to a delegate pattern -/// for the signal to restart the animation. +/// - Note: Unfortunately `LightweightObservable` doesn't support signals (yet), therefore we fallback to a delegate pattern +/// for the signal to start / stop the animation. protocol GradientActivityIndicatorViewModelDelegate: AnyObject { - /// Informs the delegate to restart the animation, as some related values have changed. - func restartAnimation() + /// Informs the delegate to start animating the locations of the gradient colors. + /// + /// - Parameters: + /// - values: Array containing an array of locations, that are used to position the gradient colors. + /// - duration: The entire duration to animate all locations with. + func startAnimatingLocations(values: GradientLocationAnimationMatrix, duration: TimeInterval) + + /// Informs the delegate to stop animating the locations of the gradient colors. + func stopAnimatingLocations() } -/// This view model contains all logic related to the `GradientActivityIndicatorView`. +/// This view model contains all logic related to the `GradientActivityIndicatorView` and the corresponding animation. +/// +/// # How to create the infinite animation +/// Example for `gradientColors = [.red, .yellow, .green, .blue]` +/// +/// Given the above `gradientColors` we can define three animation states: +/// - Visible colors at the **start** of the animation: `[.red, .yellow, .green, .blue]` +/// - Visible colors in the **middle** of the animation: `[.blue, .green, .yellow, .red]` (inverted `gradientColors`) +/// - Visible colors at the **end** of the animation: `[.red, .yellow, .green, .blue]` (same as start `gradientColors`) +/// +/// So first thing we're gonna do is to create a single array of all colors used in the above states. +/// Therefore we'll duplicate the `gradientColors`, reverse them, and remove the first and last item of the reversed array in order to +/// prevent duplicate values at the "inner edges" destroying the infinite look. Afterwards we can append the `gradientColors` again. +/// +/// Given the above `gradientColors` our corresponding `gradientLayerColors` will look like this: +/// `gradientLayerColors = [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue]` +/// +/// Now we can animate through all of the colors, by updating the `locations` property accordingly. Please have a look at the documentation +/// for the method `makeGradientLocationAnimationMatrix()` for further details regarding the `locations` property. +/// +/// As the colors at the start are the same as at the end, we can loop the animation without visual artefacts. final class GradientActivityIndicatorViewModel { // MARK: - Public properties - /// Observable state of the progress animation. - var isAnimatingProgress: Observable { - return isAnimatingProgressSubject.asObservable - } - - /// Observable frame of the gradient layer. - var gradientLayerFrame: Observable { - return gradientLayerFrameSubject.asObservable - } - - /// Observable starting point (x) of the animation of the gradient layer. - var gradientLayerAnimationFromValue: Observable { - return gradientLayerAnimationFromValueSubject.asObservable - } - - /// Observable duration of the animation of the gradient layer. - var gradientLayerAnimationDuration: Observable { - return gradientLayerAnimationDurationSubject.asObservable - } - /// Observable color array for the gradient layer (of type `CGColor`). var gradientLayerColors: Observable<[CGColor]> { - return gradientLayerColorsSubject.asObservable + gradientLayerColorsSubject.asObservable } - /// Boolean flag, whether the view is currently hidden. - var isHidden = false { - didSet { - isAnimatingProgressSubject.value = !isHidden - } - } - - /// The bounds of the view. - var bounds: CGRect = .zero { - didSet { - // Three times of the width in order to apply normal, reversed and normal gradient to simulate infinte animation. - gradientLayerFrameSubject.value = CGRect(x: 0, y: 0, width: 3 * bounds.size.width, height: bounds.size.height) - - // Update `fromValue` of animation accordingly. - gradientLayerAnimationFromValueSubject.value = -2 * bounds.size.width - - updateProgressAnimationIfNeeded() - } + /// The (initial) color locations for the gradient layer. + var gradientLayerLocations: Observable<[NSNumber]> { + gradientLayerLocationsSubject.asObservable } /// Color array used for the gradient (of type `UIColor`). var gradientColors = UIColor.GradientLoadingBar.gradientColors { didSet { gradientLayerColorsSubject.value = makeGradientLayerColors() + gradientLayerLocationsSubject.value = makeGradientLocationAnimationMatrixInitialRow() } } /// The duration for the progress animation. - var progressAnimationDuration = TimeInterval.GradientLoadingBar.progressDuration { - didSet { - gradientLayerAnimationDurationSubject.value = progressAnimationDuration + var progressAnimationDuration = TimeInterval.GradientLoadingBar.progressDuration - updateProgressAnimationIfNeeded() + /// Boolean flag, whether the view is currently hidden. + var isHidden = false { + didSet { + if isHidden { + stopAnimatingLocations() + } else { + startAnimatingLocations() + } } } @@ -87,33 +92,25 @@ final class GradientActivityIndicatorViewModel { // MARK: - Private properties - // As our view is initially visible, we also have to start the progress animation initially. - private let isAnimatingProgressSubject: Variable = Variable(true) + private let gradientLayerColorsSubject: Variable<[CGColor]> = Variable([]) - private let gradientLayerFrameSubject: Variable = Variable(.zero) - - private let gradientLayerAnimationFromValueSubject: Variable = Variable(0.0) - - private let gradientLayerAnimationDurationSubject: Variable - - private let gradientLayerColorsSubject: Variable<[CGColor]> + private let gradientLayerLocationsSubject: Variable<[NSNumber]> = Variable([]) // MARK: - Initializer init() { - gradientLayerAnimationDurationSubject = Variable(progressAnimationDuration) - - // Small workaround as calls to `self.makeGradientLayerColors()` aren't allowed before all properties have been initialized. - gradientLayerColorsSubject = Variable([]) gradientLayerColorsSubject.value = makeGradientLayerColors() + gradientLayerLocationsSubject.value = makeGradientLocationAnimationMatrixInitialRow() } // MARK: - Private methods - /// Maps the current `gradientColors` given by the user as an array of `UIColor`, - /// to an array of type `CGColor`, so we can use it for our gradient layer. + /// Generates the colors used on the gradient-layer. + /// + /// Example for `gradientColors = [.red, .yellow, .green, .blue]` + /// and therefore `gradientLayerColors = [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue]` private func makeGradientLayerColors() -> [CGColor] { - // Simulate infinte animation - Therefore we'll reverse the colors and remove the first and last item + // 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. let reversedColors = gradientColors .reversed() @@ -124,11 +121,89 @@ final class GradientActivityIndicatorViewModel { return infiniteGradientColors.map { $0.cgColor } } - /// Unfortunatly the only easy way to update a running `CABasicAnimation`, is to restart it. - /// Mutating a running animation throws an exception!! - private func updateProgressAnimationIfNeeded() { - guard isAnimatingProgress.value else { return } + /// Generates the locations for the gradient colors, + /// by placing them equally between zero and one. + /// + /// E.g. if we have `gradientColors = [.red, .yellow, .green, .blue]` + /// we need to position them as `[ 0.0, 0.33, 0.66, 0.99]`. + private func makeGradientLocations() -> [NSNumber] { + let gradientColorsQuantity = gradientColors.count + let increaseBy = 1.0 / Double(gradientColorsQuantity - 1) - delegate?.restartAnimation() + var percentageLocations = [NSNumber](repeating: 1.0, count: gradientColorsQuantity) + for index in 0 ..< gradientColorsQuantity { + let value = increaseBy * Double(index) + percentageLocations[index] = NSNumber(value: value) + } + + return percentageLocations + } + + /// Generates the first row of the `locations` matrix for animating the current `gradientColors`. + /// We use this method to initialize the corresponding property, as the animation starts from these location values. + /// + /// Example for `gradientColors = [.red, .yellow, .green, .blue]` + /// and therefore `gradientLayerColors = [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue]` + /// ``` + /// gradientLayerColors | .red | .yellow | .green | .blue | .green | .yellow | .red | .yellow | .green | .blue + /// initialLocations | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.33 | 0.66 | 1 + /// ``` + private func makeGradientLocationAnimationMatrixInitialRow() -> [NSNumber] { + let gradientColorsQuantity = gradientColors.count + let gradientLayerColorsQuantity = gradientLayerColors.value.count + + let startLocationsQuantity = gradientLayerColorsQuantity - gradientColorsQuantity + let startLocations = [NSNumber](repeating: 0.0, count: startLocationsQuantity) + + let gradientLocations = makeGradientLocations() + return startLocations + gradientLocations + } + + /// Generates the `locations` matrix for animating the current `gradientColors`. + /// + /// Example for `gradientColors = [.red, .yellow, .green, .blue]` + /// and therefore `gradientLayerColors = [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue]` + /// ``` + /// i | .red | .yellow | .green | .blue | .green | .yellow | .red | .yellow | .green | .blue + /// 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.33 | 0.66 | 1 + /// 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0.33 | 0.66 | 1 | 1 + /// 2 | 0 | 0 | 0 | 0 | 0 | 0.33 | 0.66 | 1 | 1 | 1 + /// 3 | 0 | 0 | 0 | 0 | 0.33 | 0.66 | 1 | 1 | 1 | 1 + /// 4 | 0 | 0 | 0 | 0.33 | 0.66 | 1 | 1 | 1 | 1 | 1 + /// 5 | 0 | 0 | 0.33 | 0.66 | 1 | 1 | 1 | 1 | 1 | 1 + /// 6 | 0 | 0.33 | 0.66 | 1 | 1 | 1 | 1 | 1 | 1 | 1 + /// ``` + private func makeGradientLocationAnimationMatrix() -> GradientLocationAnimationMatrix { + let gradientColorsQuantity = gradientColors.count + let gradientLayerColorsQuantity = gradientLayerColors.value.count + + let gradientLocations = makeGradientLocations() + + // As the matrix is zero based, we have to increase the value by one here. + let matrixHeight = gradientLayerColorsQuantity - gradientColorsQuantity + 1 + + var locationsMatrix = GradientLocationAnimationMatrix(repeating: [], count: matrixHeight) + for index in 0 ..< matrixHeight { + let startLocationsQuantity = gradientLayerColorsQuantity - gradientColorsQuantity - index + let startLocations = [NSNumber](repeating: 0.0, count: startLocationsQuantity) + + let endLocationsQuantity = index + let endLocations = [NSNumber](repeating: 1.0, count: endLocationsQuantity) + + locationsMatrix[index] = startLocations + gradientLocations + endLocations + } + + return locationsMatrix + } + + private func startAnimatingLocations() { + let gradientLocationAnimationMatrix = makeGradientLocationAnimationMatrix() + + delegate?.startAnimatingLocations(values: gradientLocationAnimationMatrix, + duration: progressAnimationDuration) + } + + private func stopAnimatingLocations() { + delegate?.stopAnimatingLocations() } } diff --git a/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift b/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift index a2843a0..247fc01 100644 --- a/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift +++ b/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift @@ -45,28 +45,27 @@ open class GradientActivityIndicatorView: UIView { } } + /// In order to prevent adding another sublayer and keeping the frame up-to-date, we + /// simply overwrite the `layerClass` and use a `CAGradientLayer` for this view. + open override class var layerClass: AnyClass { + return CAGradientLayer.self + } + // MARK: - Private properties /// The layer holding the gradient. - private let gradientLayer: CAGradientLayer = { - let layer = CAGradientLayer() - layer.anchorPoint = .zero - layer.startPoint = .zero - layer.endPoint = CGPoint(x: 1.0, y: 0.0) + private var gradientLayer: CAGradientLayer? { + return layer as? CAGradientLayer + } - return layer - }() - - /// The progress animation. + /// The animation used to show the "progress". /// - /// - Note: `fromValue` and `duration` are updated from the view-model. - private let animation: CABasicAnimation = { - let animation = CABasicAnimation(keyPath: "position.x") - animation.fromValue = 0 - animation.toValue = 0 + /// - Note: The properties `values` and `duration` are set in the delegate of the view-model. + private let progressAnimation: CAKeyframeAnimation = { + let animation = CAKeyframeAnimation(keyPath: "locations") animation.isRemovedOnCompletion = false animation.repeatCount = Float.infinity - animation.duration = 0 + animation.fillMode = .forwards return animation }() @@ -93,70 +92,43 @@ open class GradientActivityIndicatorView: UIView { // MARK: - Public methods - open override func layoutSubviews() { - super.layoutSubviews() - - viewModel.bounds = bounds - } - open override func point(inside _: CGPoint, with _: UIEvent?) -> Bool { // Passing all touches to the next view (if any), in the view stack. return false } - open override func prepareForInterfaceBuilder() { - super.prepareForInterfaceBuilder() - - // Not sure why we need to set this again to make it work in the interface builder. - layer.masksToBounds = true - } - // MARK: - Private methods private func commonInit() { - layer.insertSublayer(gradientLayer, at: 0) - layer.masksToBounds = true + gradientLayer?.startPoint = .zero + gradientLayer?.endPoint = CGPoint(x: 1.0, y: 0.0) viewModel.delegate = self bindViewModelToView() } private func bindViewModelToView() { - viewModel.isAnimatingProgress.subscribeDistinct { [weak self] newIsAnimatingProgress, _ in - self?.updateProgressAnimation(isAnimating: newIsAnimatingProgress) - }.disposed(by: &disposeBag) - - viewModel.gradientLayerFrame.subscribeDistinct { [weak self] newGradientLayerFrame, _ in - self?.gradientLayer.frame = newGradientLayerFrame - }.disposed(by: &disposeBag) - - viewModel.gradientLayerAnimationFromValue.subscribeDistinct { [weak self] newGradientLayerAnimationFromValue, _ in - self?.animation.fromValue = newGradientLayerAnimationFromValue - }.disposed(by: &disposeBag) - - viewModel.gradientLayerAnimationDuration.subscribeDistinct { [weak self] newGradientLayerAnimationDuration, _ in - self?.animation.duration = newGradientLayerAnimationDuration - }.disposed(by: &disposeBag) - viewModel.gradientLayerColors.subscribeDistinct { [weak self] newGradientLayerColors, _ in - self?.gradientLayer.colors = newGradientLayerColors + self?.gradientLayer?.colors = newGradientLayerColors }.disposed(by: &disposeBag) - } - private func updateProgressAnimation(isAnimating: Bool) { - if isAnimating { - gradientLayer.add(animation, forKey: GradientActivityIndicatorView.progressAnimationKey) - } else { - gradientLayer.removeAnimation(forKey: GradientActivityIndicatorView.progressAnimationKey) - } + viewModel.gradientLayerLocations.subscribe { [weak self] newGradientLayerLocations, _ in + self?.gradientLayer?.locations = newGradientLayerLocations + }.disposed(by: &disposeBag) } } -// MARK: - `GradientActivityIndicatorViewModelDelegate` conformance +// MARK: - `GradientActivityIndicatorViewModelDelegate` extension GradientActivityIndicatorView: GradientActivityIndicatorViewModelDelegate { - func restartAnimation() { - updateProgressAnimation(isAnimating: false) - updateProgressAnimation(isAnimating: true) + func startAnimatingLocations(values: GradientLocationAnimationMatrix, duration: TimeInterval) { + progressAnimation.values = values + progressAnimation.duration = duration + + gradientLayer?.add(progressAnimation, forKey: GradientActivityIndicatorView.progressAnimationKey) + } + + func stopAnimatingLocations() { + gradientLayer?.removeAnimation(forKey: GradientActivityIndicatorView.progressAnimationKey) } }