♻️ :: Animate locations property instead of entire layer

This commit is contained in:
Felix Mau
2019-10-11 17:07:08 +02:00
parent fb5def6a50
commit 8a3e71b879
3 changed files with 322 additions and 259 deletions
@@ -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)
}
}