mirror of
https://github.com/fxm90/GradientLoadingBar.git
synced 2026-06-16 12:24:31 +00:00
♻️ :: Animate locations property instead of entire layer
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Bool> {
|
||||
return isAnimatingProgressSubject.asObservable
|
||||
}
|
||||
|
||||
/// Observable frame of the gradient layer.
|
||||
var gradientLayerFrame: Observable<CGRect> {
|
||||
return gradientLayerFrameSubject.asObservable
|
||||
}
|
||||
|
||||
/// Observable starting point (x) of the animation of the gradient layer.
|
||||
var gradientLayerAnimationFromValue: Observable<CGFloat> {
|
||||
return gradientLayerAnimationFromValueSubject.asObservable
|
||||
}
|
||||
|
||||
/// Observable duration of the animation of the gradient layer.
|
||||
var gradientLayerAnimationDuration: Observable<TimeInterval> {
|
||||
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<Bool> = Variable(true)
|
||||
private let gradientLayerColorsSubject: Variable<[CGColor]> = Variable([])
|
||||
|
||||
private let gradientLayerFrameSubject: Variable<CGRect> = Variable(.zero)
|
||||
|
||||
private let gradientLayerAnimationFromValueSubject: Variable<CGFloat> = Variable(0.0)
|
||||
|
||||
private let gradientLayerAnimationDurationSubject: Variable<TimeInterval>
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user