♻️ :: Use Variable instead of delegate protocol

This commit is contained in:
Felix Mau
2019-11-28 18:22:20 +01:00
parent f66525739e
commit 35d690a81d
3 changed files with 123 additions and 221 deletions
@@ -15,7 +15,6 @@ import LightweightObservable
class GradientActivityIndicatorViewModelTestCase: XCTestCase {
// MARK: - Private properties
private var delegateMock: GradientActivityIndicatorViewModelDelegateMock!
private var viewModel: GradientActivityIndicatorViewModel!
// MARK: - Public methods
@@ -23,15 +22,11 @@ class GradientActivityIndicatorViewModelTestCase: XCTestCase {
override func setUp() {
super.setUp()
delegateMock = GradientActivityIndicatorViewModelDelegateMock()
viewModel = GradientActivityIndicatorViewModel()
viewModel.delegate = delegateMock
}
override func tearDown() {
viewModel = nil
delegateMock = nil
super.tearDown()
}
@@ -51,7 +46,7 @@ class GradientActivityIndicatorViewModelTestCase: XCTestCase {
XCTAssertEqual(receivedGradientLayerLocations.count, expectedGradientLayerLocations.count)
for (receivedLocation, expectedLocation) in zip(receivedGradientLayerLocations, expectedGradientLayerLocations) {
XCTAssertEqual(receivedLocation.doubleValue, expectedLocation.doubleValue, accuracy: Double.ulpOfOne)
XCTAssertEqual(receivedLocation.doubleValue, expectedLocation.doubleValue, accuracy: .ulpOfOne)
}
}
@@ -110,23 +105,22 @@ class GradientActivityIndicatorViewModelTestCase: XCTestCase {
// MARK: - Test property `isHidden`
func testSettingIsHiddenToTrueShouldInformDelegateToStopAnimatingLocations() {
func testSettingIsHiddenToTrueShouldSetAnimationStateToHidden() {
// When
viewModel.isHidden = true
// Then
XCTAssertEqual(delegateMock.invokedMethod, .stopAnimatingLocations)
XCTAssertEqual(viewModel.animationState.value, .inactive)
}
func testSettingIsHiddenToFalseShouldInformDelegateToStartAnimatingLocations() {
func testSettingIsHiddenToFalseShouldSetAnimationStateToActive() {
// 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!")
let progressAnimationDuration: TimeInterval = 123
viewModel.progressAnimationDuration = progressAnimationDuration
// When
viewModel.isHidden = false
@@ -155,75 +149,8 @@ class GradientActivityIndicatorViewModelTestCase: XCTestCase {
$0.map { NSNumber(value: $0) }
}
XCTAssertEqual(delegateMock.invokedMethod, .startAnimatingLocations(values: expectedValues,
duration: viewModel.progressAnimationDuration))
}
// MARK: - Test method `startAnimationIfNeeded()`
func testStartAnimationIfNeededShouldNotInformDelegateAsViewIsHidden() {
// Given
// As setting the property `isHidden` is also calling the delegate, we have to reset it afterwards.
viewModel.isHidden = true
delegateMock.reset()
// When
viewModel.startAnimationIfNeeded()
// Then
XCTAssertNil(delegateMock.invokedMethod)
}
func testStartAnimationIfNeededShouldInformDelegateToStartAnimatingLocations() {
// Given
// As setting the property `isHidden` is also calling the delegate, we have to reset it afterwards.
viewModel.isHidden = false
delegateMock.reset()
// In order to simplify the matrix, we're gonna update the `gradientColors` first.
let gradientColors: [UIColor] = [.red, .yellow, .green, .blue]
viewModel.gradientColors = gradientColors
XCTAssertNil(delegateMock.invokedMethod,
"Precondition failed Expected delegate not to be informed at this point!")
// When
viewModel.startAnimationIfNeeded()
// Then
//
// `gradientColors = [.red, .yellow, .green, .blue]`
// `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
//
let oneThird = 1.0 / 3.0
let twoThird = 2.0 / 3.0
let gradientLocationsMatrix = [
[0, 0, 0, 0, 0, 0, 0, oneThird, twoThird, 1],
[0, 0, 0, 0, 0, 0, oneThird, twoThird, 1, 1],
[0, 0, 0, 0, 0, oneThird, twoThird, 1, 1, 1],
[0, 0, 0, 0, oneThird, twoThird, 1, 1, 1, 1],
[0, 0, 0, oneThird, twoThird, 1, 1, 1, 1, 1],
[0, 0, oneThird, twoThird, 1, 1, 1, 1, 1, 1],
[0, oneThird, twoThird, 1, 1, 1, 1, 1, 1, 1]
]
let expectedValues = gradientLocationsMatrix.map {
$0.map { NSNumber(value: $0) }
}
XCTAssertEqual(delegateMock.invokedMethod, .startAnimatingLocations(values: expectedValues,
duration: viewModel.progressAnimationDuration))
XCTAssertEqual(viewModel.animationState.value, .active(values: expectedValues,
duration: progressAnimationDuration))
}
}
@@ -274,35 +201,3 @@ extension GradientActivityIndicatorViewModelTestCase {
return gradientLocationAnimationMatrixInitialRow.map { NSNumber(value: $0) }
}
}
// MARK: - Mocks
// swiftlint:disable:next type_name
private class GradientActivityIndicatorViewModelDelegateMock: GradientActivityIndicatorViewModelDelegate {
// MARK: - Types
enum DelegateMethod: Equatable {
case startAnimatingLocations(values: GradientLocationAnimationMatrix, duration: TimeInterval)
case stopAnimatingLocations
}
// MARK: - Public properties
private(set) var invokedMethod: DelegateMethod?
// MARK: - Public methods
func reset() {
invokedMethod = nil
}
// MARK: - `GradientActivityIndicatorViewModelDelegate`
func startAnimatingLocations(values: GradientLocationAnimationMatrix, duration: TimeInterval) {
invokedMethod = .startAnimatingLocations(values: values, duration: duration)
}
func stopAnimatingLocations() {
invokedMethod = .stopAnimatingLocations
}
}
@@ -11,26 +11,13 @@ import LightweightObservable
// MARK: - Types
/// Array containing an array of locations, that are used to position the gradient colors during the animation:
/// Array of locations, used to position the gradient colors during the animation.
typealias GradientLocationRow = [NSNumber]
/// Array containing an array of locations, 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: 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 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()
}
typealias GradientLocationMatrix = [GradientLocationRow]
/// This view model contains all logic related to the `GradientActivityIndicatorView` and the corresponding animation.
///
@@ -54,6 +41,20 @@ protocol GradientActivityIndicatorViewModelDelegate: AnyObject {
///
/// 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: - Types
enum AnimationState: Equatable {
/// Start animating the locations of the gradient colors.
///
/// - Parameters:
/// - values: Array containing an array of locations, used to position the gradient colors during the animation.
/// - duration: The entire duration to animate all locations with.
case active(values: GradientLocationMatrix, duration: TimeInterval)
/// Stop animating the locations of the gradient colors.
case inactive
}
// MARK: - Public properties
/// Observable color array for the gradient layer (of type `CGColor`).
@@ -61,63 +62,95 @@ final class GradientActivityIndicatorViewModel {
gradientLayerColorsSubject
}
/// The (initial) color locations for the gradient layer.
var gradientLayerLocations: Observable<[NSNumber]> {
/// Observable color locations for the gradient layer.
var gradientLayerLocations: Observable<GradientLocationRow> {
gradientLayerLocationsSubject
}
/// Observable current animation state.
var animationState: Observable<AnimationState> {
animationStateSubject
}
/// Color array used for the gradient (of type `UIColor`).
var gradientColors = UIColor.GradientLoadingBar.gradientColors {
didSet {
gradientLayerColorsSubject.value = makeGradientLayerColors()
gradientLayerLocationsSubject.value = makeGradientLocationAnimationMatrixInitialRow()
// In order to have a working animation we need to provide the initial gradient-locations,
// which is the first row of our animation matrix.
gradientLayerLocationsSubject.value = makeGradientLocationRow(index: 0)
}
}
/// The duration for the progress animation.
var progressAnimationDuration = TimeInterval.GradientLoadingBar.progressDuration
/// Boolean flag, whether the view is currently hidden.
/// Boolean flag whether the view is currently hidden.
var isHidden = false {
didSet {
if isHidden {
stopAnimatingLocations()
animationStateSubject.value = .inactive
} else {
startAnimatingLocations()
animationStateSubject.value = .active(values: makeGradientLocationMatrix(),
duration: progressAnimationDuration)
}
}
}
weak var delegate: GradientActivityIndicatorViewModelDelegate?
// MARK: - Private properties
private let gradientLayerColorsSubject: Variable<[CGColor]> = Variable([])
private let gradientLayerLocationsSubject: Variable<[NSNumber]> = Variable([])
private let gradientLayerColorsSubject: Variable<[CGColor]>
private let gradientLayerLocationsSubject: Variable<[NSNumber]>
private let animationStateSubject: Variable<AnimationState>
// MARK: - Initializer
init() {
gradientLayerColorsSubject.value = makeGradientLayerColors()
gradientLayerLocationsSubject.value = makeGradientLocationAnimationMatrixInitialRow()
}
let gradientLayerColors = Self.makeGradientLayerColors(from: gradientColors)
let gradientLocationMatrix = Self.makeGradientLocationMatrix(gradientColorsQuantity: gradientColors.count,
gradientLayerColorsQuantity: gradientLayerColors.count)
// MARK: - Public methods
gradientLayerColorsSubject = Variable(gradientLayerColors)
func startAnimationIfNeeded() {
guard !isHidden else { return }
// In order to have a working animation we need to provide the initial gradient-locations,
// which is the first row of our animation matrix.
gradientLayerLocationsSubject = Variable(gradientLocationMatrix[0])
startAnimatingLocations()
// As the view is visible initially we already have to start the animation here.
animationStateSubject = Variable(.active(values: gradientLocationMatrix,
duration: progressAnimationDuration))
}
// MARK: - Private methods
/// Forward calls to the static factory method `makeGradientLayerColors(from:)` by providing the required parameters.
private func makeGradientLayerColors() -> [CGColor] {
Self.makeGradientLayerColors(from: gradientColors)
}
/// Forward calls to the static factory method `makeGradientLocationRow(index:gradientColorsQuantity:gradientLayerColorsQuantity:)`
/// by providing the required parameters.
private func makeGradientLocationRow(index: Int) -> GradientLocationRow {
Self.makeGradientLocationRow(index: index,
gradientColorsQuantity: gradientColors.count,
gradientLayerColorsQuantity: gradientLayerColorsSubject.value.count)
}
/// Forward calls to the static factory method `makeGradientLocationMatrix(gradientColorsQuantity:gradientLayerColorsQuantity:)`
/// by providing the required parameters.
private func makeGradientLocationMatrix() -> GradientLocationMatrix {
Self.makeGradientLocationMatrix(gradientColorsQuantity: gradientColors.count,
gradientLayerColorsQuantity: gradientLayerColorsSubject.value.count)
}
/// 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] {
///
/// - Note: Declared `static` so we can call this method from the initializer, before `self` is available.
private static func makeGradientLayerColors(from gradientColors: [UIColor]) -> [CGColor] {
// 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
@@ -129,45 +162,39 @@ final class GradientActivityIndicatorViewModel {
return infiniteGradientColors.map { $0.cgColor }
}
/// Generates the locations for the gradient colors,
/// by placing them equally between zero and one.
/// Generates a single row for the locations-matrix used for animating the current `gradientColors`.
///
/// 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)
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]`
/// Example for `index = 0`
/// `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
/// gradientLocationRow | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.33 | 0.66 | 1
/// ```
private func makeGradientLocationAnimationMatrixInitialRow() -> [NSNumber] {
let gradientColorsQuantity = gradientColors.count
let gradientLayerColorsQuantity = gradientLayerColorsSubject.value.count
let startLocationsQuantity = gradientLayerColorsQuantity - gradientColorsQuantity
///
/// - Note: Declared `static` so we can call this method from the initializer, before `self` is available.
private static func makeGradientLocationRow(index: Int,
gradientColorsQuantity: Int,
gradientLayerColorsQuantity: Int) -> GradientLocationRow {
let startLocationsQuantity = gradientLayerColorsQuantity - gradientColorsQuantity - index
let startLocations = [NSNumber](repeating: 0.0, count: startLocationsQuantity)
let gradientLocations = makeGradientLocations()
return startLocations + gradientLocations
// E.g. if we have `gradientColors = [.red, .yellow, .green, .blue]`
// we need to position them as `[ 0.0, 0.33, 0.66, 0.99]`.
let increaseBy = 1.0 / Double(gradientColorsQuantity - 1)
let range = 0 ..< gradientColorsQuantity
let gradientLocations = range.reduce(into: GradientLocationRow(repeating: 1.0, count: gradientColorsQuantity)) { gradientLocations, col in
let value = Double(col) * increaseBy
gradientLocations[col] = value as NSNumber
}
let endLocationsQuantity = index
let endLocations = [NSNumber](repeating: 1.0, count: endLocationsQuantity)
return startLocations + gradientLocations + endLocations
}
/// Generates the `locations` matrix for animating the current `gradientColors`.
/// Generates the locations-matrix used 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]`
@@ -181,37 +208,17 @@ final class GradientActivityIndicatorViewModel {
/// 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 = gradientLayerColorsSubject.value.count
let gradientLocations = makeGradientLocations()
// As the matrix is zero based, we have to increase the value by one here.
///
/// - Note: Declared `static` so we can call this method from the initializer, before `self` is available.
private static func makeGradientLocationMatrix(gradientColorsQuantity: Int,
gradientLayerColorsQuantity: Int) -> GradientLocationMatrix {
let matrixHeight = gradientLayerColorsQuantity - gradientColorsQuantity + 1
let range = 0 ..< matrixHeight
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 range.reduce(into: GradientLocationMatrix(repeating: [], count: matrixHeight)) { gradientLocationMatrix, row in
gradientLocationMatrix[row] = Self.makeGradientLocationRow(index: row,
gradientColorsQuantity: gradientColorsQuantity,
gradientLayerColorsQuantity: gradientLayerColorsQuantity)
}
return locationsMatrix
}
private func startAnimatingLocations() {
let gradientLocationAnimationMatrix = makeGradientLocationAnimationMatrix()
delegate?.startAnimatingLocations(values: gradientLocationAnimationMatrix,
duration: progressAnimationDuration)
}
private func stopAnimatingLocations() {
delegate?.stopAnimatingLocations()
}
}
@@ -59,8 +59,6 @@ open class GradientActivityIndicatorView: UIView {
}
/// The animation used to show the "progress".
///
/// - 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
@@ -103,11 +101,7 @@ open class GradientActivityIndicatorView: UIView {
gradientLayer?.startPoint = .zero
gradientLayer?.endPoint = CGPoint(x: 1.0, y: 0.0)
viewModel.delegate = self
bindViewModelToView()
// As the view is visible initially, we might already have to start the animation.
viewModel.startAnimationIfNeeded()
}
private func bindViewModelToView() {
@@ -118,20 +112,26 @@ open class GradientActivityIndicatorView: UIView {
viewModel.gradientLayerLocations.subscribe { [weak self] newGradientLayerLocations, _ in
self?.gradientLayer?.locations = newGradientLayerLocations
}.disposed(by: &disposeBag)
viewModel.animationState.subscribeDistinct { [weak self] newAnimationState, _ in
switch newAnimationState {
case let .active(values, duration):
self?.startAnimatingLocations(values: values, duration: duration)
case .inactive:
self?.stopAnimatingLocations()
}
}.disposed(by: &disposeBag)
}
}
// MARK: - `GradientActivityIndicatorViewModelDelegate`
extension GradientActivityIndicatorView: GradientActivityIndicatorViewModelDelegate {
func startAnimatingLocations(values: GradientLocationAnimationMatrix, duration: TimeInterval) {
private func startAnimatingLocations(values: GradientLocationMatrix, duration: TimeInterval) {
progressAnimation.values = values
progressAnimation.duration = duration
gradientLayer?.add(progressAnimation, forKey: GradientActivityIndicatorView.progressAnimationKey)
}
func stopAnimatingLocations() {
private func stopAnimatingLocations() {
gradientLayer?.removeAnimation(forKey: GradientActivityIndicatorView.progressAnimationKey)
}
}