diff --git a/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift b/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift index 74ddc1e..8c3706f 100644 --- a/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift +++ b/GradientLoadingBar/Classes/ViewModel/GradientActivityIndicatorViewModel.swift @@ -6,193 +6,135 @@ // Copyright © 2019 Felix Mau. All rights reserved. // -import UIKit +import Foundation import LightweightObservable -// MARK: - Types - -/// Array of locations, used to position the gradient colors during the animation. -typealias ColorLocationRow = [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 ColorLocationMatrix = [ColorLocationRow] - -/// 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 we have 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 `makeColorLocationMatrix(gradientColorsQuantity:gradientLayerColorsQuantity:)` 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. +/// This view model contains all logic related to the `GradientActivityIndicatorView`. final class GradientActivityIndicatorViewModel { + // MARK: - Types + + struct SizeUpdate: Equatable { + let frame: CGRect + let fromValue: CGFloat + } + // MARK: - Public properties + /// Observable state of the progress animation. + var isAnimating: Observable { + isAnimatingSubject + } + + /// Observable frame of the gradient layer. + var gradientLayerSizeUpdate: Observable { + gradientLayerSizeUpdateSubject + } + /// Observable color array for the gradient layer (of type `CGColor`). var gradientLayerColors: Observable<[CGColor]> { gradientLayerColorsSubject } - /// Observable color location matrix, 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. - var colorLocationMatrix: Observable { - colorLocationMatrixSubject + /// Observable duration of the animation of the gradient layer. + var gradientLayerAnimationDuration: Observable { + gradientLayerAnimationDurationSubject } - /// Observable duration for the progress animation. - var animationDuration: Observable { - animationDurationSubject - } - - /// Observable flag, whether we're currently animating the progress. - var isAnimating: Observable { - isAnimatingSubject - } - - /// Color array used for the gradient (of type `UIColor`). - var gradientColors = UIColor.GradientLoadingBar.gradientColors { - didSet { - let gradientLayerColors = ColorLocationRow.gradientLayerColors(from: gradientColors) - gradientLayerColorsSubject.value = gradientLayerColors - - let gradientLocationMatrix = ColorLocationMatrix.colorLocationMatrix(gradientColorsQuantity: gradientColors.count, - gradientLayerColorsQuantity: gradientLayerColors.count) - - colorLocationMatrixSubject.value = gradientLocationMatrix - } - } - - /// The duration for the progress animation. - var progressAnimationDuration: TimeInterval { - get { - animationDurationSubject.value - } - set { - animationDurationSubject.value = newValue - } - } - - /// Boolean flag whether the view is currently hidden. + /// Boolean flag, whether the view is currently hidden. var isHidden = false { didSet { isAnimatingSubject.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 + // in order to simulate infinite animation. + var frame = bounds + frame.size.width *= 3 + + gradientLayerSizeUpdateSubject.value = SizeUpdate(frame: frame, + fromValue: bounds.width * -2) + } + } + + /// Color array used for the gradient (of type `UIColor`). + var gradientColors = UIColor.GradientLoadingBar.gradientColors { + didSet { + gradientLayerColorsSubject.value = gradientColors.infiniteLayerColors() + } + } + + /// The duration for the progress animation. + /// + /// - Note: We explicitly have to pass this through the view-model in order to restart the animation when this value changes + /// while the loading bar is visible. + var progressAnimationDuration = TimeInterval.GradientLoadingBar.progressDuration { + didSet { + gradientLayerAnimationDurationSubject.value = progressAnimationDuration + } + } + // MARK: - Private properties - private let gradientLayerColorsSubject: Variable<[CGColor]> - private let colorLocationMatrixSubject: Variable - - private let animationDurationSubject = Variable(TimeInterval.GradientLoadingBar.progressDuration) + // As our view is initially visible, we also have to start the progress animation initially. private let isAnimatingSubject = Variable(true) + private let gradientLayerSizeUpdateSubject = Variable(SizeUpdate(frame: .zero, fromValue: 0)) + + private let gradientLayerColorsSubject: Variable<[CGColor]> + private let gradientLayerAnimationDurationSubject: Variable + + private var disposeBag = DisposeBag() // MARK: - Initializer init() { - let gradientLayerColors = ColorLocationRow.gradientLayerColors(from: gradientColors) - let gradientLocationMatrix = ColorLocationMatrix.colorLocationMatrix(gradientColorsQuantity: gradientColors.count, - gradientLayerColorsQuantity: gradientLayerColors.count) + gradientLayerColorsSubject = Variable(gradientColors.infiniteLayerColors()) + gradientLayerAnimationDurationSubject = Variable(progressAnimationDuration) - gradientLayerColorsSubject = Variable(gradientLayerColors) - colorLocationMatrixSubject = Variable(gradientLocationMatrix) + gradientLayerAnimationDuration.subscribe { [weak self] _, _ in + self?.restartAnimationIfNeeded() + }.disposed(by: &disposeBag) + + gradientLayerSizeUpdate.subscribe { [weak self] _, _ in + self?.restartAnimationIfNeeded() + }.disposed(by: &disposeBag) + } + + // MARK: - Private methods + + /// Unfortunately the only easy way to update a running `CABasicAnimation`, is to restart it. + /// Mutating a running animation throws an exception!! + private func restartAnimationIfNeeded() { + guard isAnimatingSubject.value else { return } + + isAnimatingSubject.value = false + isAnimatingSubject.value = true } } -// MARK: - Helpers +// MARK: - Helperz -private extension ColorLocationRow { - /// Generates the colors used on the gradient-layer. +extension Array where Element == UIColor { /// - /// Example for `gradientColors = [.red, .yellow, .green, .blue]` - /// and therefore `gradientLayerColors = [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue]` /// - /// - Note: Declared `static` so we can call this method from the initializer, before `self` is available. - static func gradientLayerColors(from gradientColors: [UIColor]) -> [CGColor] { + func infiniteLayerColors() -> [CGColor] { + let cgColors = map { $0.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 - .reversed() + // + // E.g. for array of [.red, .yellow, .green] + // we will create [.red, .yellow, .green, .yellow, .red, .yellow, .green] + // + // E.g. for array of [.red, .yellow, .green, .blue] + // we will create [.red, .yellow, .green, .blue, .green, .yellow, .red, .yellow, .green, .blue] + let reversedColors = cgColors.reversed() .dropFirst() .dropLast() - let infiniteGradientColors = gradientColors + reversedColors + gradientColors - return infiniteGradientColors.map { $0.cgColor } - } -} - -private extension ColorLocationMatrix { - /// Generates a single row for the locations-matrix used for animating the current `gradientColors`. - /// - /// 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 - /// gradientLocationRow | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.33 | 0.66 | 1 - /// ``` - /// - /// - Note: Declared `static` so we can call this method from the initializer, before `self` is available. - private static func colorLocationRow(index: Int, gradientColorsQuantity: Int, gradientLayerColorsQuantity: Int) -> ColorLocationRow { - let startLocationsQuantity = gradientLayerColorsQuantity - gradientColorsQuantity - index - let startLocations = [NSNumber](repeating: 0.0, count: startLocationsQuantity) - - // 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: ColorLocationRow(repeating: 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 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]` - /// ``` - /// 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 - /// ``` - /// - /// - Note: Declared `static` so we can call this method from the initializer, before `self` is available. - static func colorLocationMatrix(gradientColorsQuantity: Int, gradientLayerColorsQuantity: Int) -> ColorLocationMatrix { - let matrixHeight = gradientLayerColorsQuantity - gradientColorsQuantity + 1 - let range = 0 ..< matrixHeight - - return range.reduce(into: ColorLocationMatrix(repeating: [], count: matrixHeight)) { gradientLocationMatrix, row in - gradientLocationMatrix[row] = Self.colorLocationRow(index: row, - gradientColorsQuantity: gradientColorsQuantity, - gradientLayerColorsQuantity: gradientLayerColorsQuantity) - } + return cgColors + reversedColors + cgColors } } diff --git a/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift b/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift index 93686a0..5928f26 100644 --- a/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift +++ b/GradientLoadingBar/Classes/Views/GradientActivityIndicatorView.swift @@ -39,25 +39,28 @@ open class GradientActivityIndicatorView: UIView { set { viewModel.progressAnimationDuration = newValue } } - /// 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. - override open class var layerClass: AnyClass { - CAGradientLayer.self - } - // MARK: - Private properties /// The layer holding the gradient. - private var gradientLayer: CAGradientLayer? { - layer as? CAGradientLayer - } + private let gradientLayer: CAGradientLayer = { + let layer = CAGradientLayer() + layer.anchorPoint = .zero + layer.startPoint = .zero + layer.endPoint = CGPoint(x: 1.0, y: 0.0) - /// The animation used to show the "progress". - private let progressAnimation: CAKeyframeAnimation = { - let animation = CAKeyframeAnimation(keyPath: "locations") + return layer + }() + + /// The progress animation. + /// + /// - 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 animation.isRemovedOnCompletion = false animation.repeatCount = Float.infinity - animation.fillMode = .forwards + animation.duration = 0 return animation }() @@ -84,6 +87,12 @@ open class GradientActivityIndicatorView: UIView { // MARK: - Public methods + override open func layoutSubviews() { + super.layoutSubviews() + + viewModel.bounds = bounds + } + override open func point(inside _: CGPoint, with _: UIEvent?) -> Bool { // Passing all touches to the next view (if any), in the view stack. false @@ -92,39 +101,36 @@ open class GradientActivityIndicatorView: UIView { // MARK: - Private methods private func commonInit() { - gradientLayer?.startPoint = .zero - gradientLayer?.endPoint = CGPoint(x: 1, y: 0) + layer.insertSublayer(gradientLayer, at: 0) + layer.masksToBounds = true bindViewModelToView() } private func bindViewModelToView() { - viewModel.gradientLayerColors.subscribeDistinct { [weak self] newGradientLayerColors, _ in - self?.gradientLayer?.colors = newGradientLayerColors + viewModel.isAnimating.subscribeDistinct { [weak self] isAnimating, _ in + self?.updateProgressAnimation(isAnimating: isAnimating) }.disposed(by: &disposeBag) - viewModel.colorLocationMatrix.subscribeDistinct { [weak self] newColorLocationMatrix, _ in - self?.progressAnimation.values = newColorLocationMatrix - - // In order to have a working animation we need to provide the initial gradient-locations, - // which is the first row of our animation matrix. - self?.gradientLayer?.locations = newColorLocationMatrix.first + viewModel.gradientLayerSizeUpdate.subscribeDistinct { [weak self] sizeUpdate, _ in + self?.gradientLayer.frame = sizeUpdate.frame + self?.animation.fromValue = sizeUpdate.fromValue }.disposed(by: &disposeBag) - viewModel.animationDuration.subscribeDistinct { [weak self] newAnimationDuration, _ in - self?.progressAnimation.duration = newAnimationDuration + viewModel.gradientLayerColors.subscribeDistinct { [weak self] gradientColors, _ in + self?.gradientLayer.colors = gradientColors }.disposed(by: &disposeBag) - viewModel.isAnimating.subscribeDistinct { [weak self] newIsAnimating, _ in - self?.updateAnimation(isAnimating: newIsAnimating) + viewModel.gradientLayerAnimationDuration.subscribeDistinct { [weak self] animationDuration, _ in + self?.animation.duration = animationDuration }.disposed(by: &disposeBag) } - private func updateAnimation(isAnimating: Bool) { + private func updateProgressAnimation(isAnimating: Bool) { if isAnimating { - gradientLayer?.add(progressAnimation, forKey: Config.progressAnimationKey) + gradientLayer.add(animation, forKey: Config.progressAnimationKey) } else { - gradientLayer?.removeAnimation(forKey: Config.progressAnimationKey) + gradientLayer.removeAnimation(forKey: Config.progressAnimationKey) } } }