diff --git a/MVMCoreUI/Atomic/Atoms/Views/LoadingSpinner.swift b/MVMCoreUI/Atomic/Atoms/Views/LoadingSpinner.swift new file mode 100644 index 00000000..fbe49535 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/LoadingSpinner.swift @@ -0,0 +1,181 @@ +// +// LoadingSpinner.swift +// MVMCoreUI +// +// Created by Kevin Christiano on 5/20/20. +// Copyright © 2020 Verizon Wireless. All rights reserved. +// + +import UIKit + + +open class LoadingSpinner: View { + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + + public var strokeColor: UIColor = .mvmBlack + + public var lineWidth: CGFloat = 3.0 + + public var speed: Float = 1.5 + + //-------------------------------------------------- + // MARK: - Lifecycle + //-------------------------------------------------- + + override open func setupView() { + super.setupView() + + // TODO: remove + pinWidthAndHeight(radius: 20) + } + + override open var layer: CAShapeLayer { + get { return super.layer as! CAShapeLayer } + } + + override open class var layerClass: AnyClass { + return CAShapeLayer.self + } + + override open func layoutSubviews() { + super.layoutSubviews() + layer.fillColor = nil + layer.strokeColor = strokeColor.cgColor + layer.lineWidth = lineWidth + layer.lineCap = .butt + layer.speed = speed + layer.path = UIBezierPath(ovalIn: bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)).cgPath + } + + //-------------------------------------------------- + // MARK: - Animation + //-------------------------------------------------- + + override open func didMoveToWindow() { + animate() + } + + struct Pose { + let secondsSincePriorPose: CFTimeInterval + let start: CGFloat + let length: CGFloat + init(_ secondsSincePriorPose: CFTimeInterval, _ start: CGFloat, _ length: CGFloat) { + self.secondsSincePriorPose = secondsSincePriorPose + self.start = start + self.length = length + } + } + + // TODO: This needs more attention + class var poses: [Pose] { + get { + return [ + Pose(0.0, 0.000, 0.8), + Pose(0.6, 0.500, 0.5), + Pose(0.6, 1.000, 0.3), + Pose(0.6, 1.500, 0.1), + Pose(0.2, 1.875, 0.1), + Pose(0.2, 2.250, 0.3), + Pose(0.2, 2.625, 0.5), + Pose(0.2, 3.000, 0.7) + ] + } + } + + private func animate() { + var time: CFTimeInterval = 0 + var times = [CFTimeInterval]() + var start: CGFloat = 0 + var rotations = [CGFloat]() + var strokeEnds = [CGFloat]() + + let poses = Self.poses + let totalSeconds = poses.reduce(0) { $0 + $1.secondsSincePriorPose } + + for pose in poses { + time += pose.secondsSincePriorPose + times.append(time / totalSeconds) + start = pose.start + rotations.append(start * 2 * CGFloat.pi) + strokeEnds.append(pose.length) + } + + times.append(times.last!) + rotations.append(rotations[0]) + strokeEnds.append(strokeEnds[0]) + + animateKeyPath(keyPath: "strokeEnd", duration: totalSeconds, times: times, values: strokeEnds) + animateKeyPath(keyPath: "transform.rotation", duration: totalSeconds, times: times, values: rotations) + } + + private func animateKeyPath(keyPath: String, duration: CFTimeInterval, times: [CFTimeInterval], values: [CGFloat]) { + + let animation = CAKeyframeAnimation(keyPath: keyPath) + animation.keyTimes = times as [NSNumber]? + animation.values = values + animation.calculationMode = .linear + animation.duration = duration + animation.rotationMode = .rotateAuto + animation.isRemovedOnCompletion = false + animation.repeatCount = .infinity + layer.add(animation, forKey: animation.keyPath) + } + + //-------------------------------------------------- + // MARK: - Methods + //-------------------------------------------------- + + func resumeSpinnerAfterDelay() { + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.resumeAnimations() + } + } + + func pauseAnimations() { + let pausedTime = layer.convertTime(CACurrentMediaTime(), from: nil) + layer.speed = 0 + isHidden = true + layer.timeOffset = pausedTime + } + + func resumeAnimations() { + let pausedTime = layer.timeOffset + isHidden = false + layer.speed = speed + layer.timeOffset = 0 + layer.beginTime = 0 + let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime + layer.beginTime = timeSincePause + } + + func stopAllAnimations() { + layer.removeAllAnimations() + } + + func pinWidthAndHeight(radius: CGFloat) { + let diameter: CGFloat = radius * 2 + lineWidth + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: diameter), + widthAnchor.constraint(equalToConstant: diameter) + ]) + } + + //-------------------------------------------------- + // MARK: - MoleculeViewProtocol + //-------------------------------------------------- + + public override func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { + super.set(with: model, delegateObject, additionalData) + guard let model = model as? LoadingSpinnerModel else { return } + + strokeColor = model.strokeColor.uiColor + lineWidth = model.lineWidth + } + + open override class func estimatedHeight(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?) -> CGFloat? { + return 40.0 + } +} diff --git a/MVMCoreUI/Atomic/Atoms/Views/LoadingSpinnerModel.swift b/MVMCoreUI/Atomic/Atoms/Views/LoadingSpinnerModel.swift new file mode 100644 index 00000000..604c0689 --- /dev/null +++ b/MVMCoreUI/Atomic/Atoms/Views/LoadingSpinnerModel.swift @@ -0,0 +1,58 @@ +// +// LoadingSpinnerModel.swift +// MVMCoreUI +// +// Created by Kevin Christiano on 5/20/20. +// Copyright © 2020 Verizon Wireless. All rights reserved. +// + +import Foundation + + +open class LoadingSpinnerModel: MoleculeModelProtocol { + //-------------------------------------------------- + // MARK: - Properties + //-------------------------------------------------- + + public var backgroundColor: Color? + public static var identifier: String = "loadingSpinner" + public var strokeColor = Color(uiColor: .mvmBlack) + public var lineWidth: CGFloat = 3 + + //-------------------------------------------------- + // MARK: - Keys + //-------------------------------------------------- + + private enum CodingKeys: String, CodingKey { + case moleculeName + case backgroundColor + case strokeColor + case lineWidth + } + + //-------------------------------------------------- + // MARK: - Codec + //-------------------------------------------------- + + required public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + + backgroundColor = try typeContainer.decodeIfPresent(Color.self, forKey: .backgroundColor) + + if let strokeColor = try typeContainer.decodeIfPresent(Color.self, forKey: .strokeColor) { + self.strokeColor = strokeColor + } + + if let lineWidth = try typeContainer.decodeIfPresent(CGFloat.self, forKey: .lineWidth) { + self.lineWidth = lineWidth + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(moleculeName, forKey: .moleculeName) + try container.encodeIfPresent(backgroundColor, forKey: .backgroundColor) + try container.encode(strokeColor, forKey: .strokeColor) + try container.encode(lineWidth, forKey: .lineWidth) + } +}