From 35d690a81de39d186e4bfadee986191a9db74802 Mon Sep 17 00:00:00 2001 From: Felix Mau Date: Thu, 28 Nov 2019 18:22:20 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20::=20Use=20`Variable`=20in?= =?UTF-8?q?stead=20of=20delegate=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ntActivityIndicatorViewModelTestCase.swift | 121 +---------- .../GradientActivityIndicatorViewModel.swift | 199 +++++++++--------- .../Views/GradientActivityIndicatorView.swift | 24 +-- 3 files changed, 123 insertions(+), 221 deletions(-) diff --git a/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift b/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift index 1d9f9d0..54447ff 100644 --- a/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift +++ b/Example/Tests/ViewModel/GradientActivityIndicatorViewModelTestCase.swift @@ -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 - } -} diff --git a/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift b/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift index 0f1b6ed..5e071d2 100644 --- a/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift +++ b/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift @@ -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 { gradientLayerLocationsSubject } + /// Observable current animation state. + var animationState: Observable { + 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 // 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() } } diff --git a/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift b/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift index d856279..4423b60 100644 --- a/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift +++ b/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift @@ -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) } }