// MIT License // // Copyright (c) 2017-2019 MessageKit // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. import UIKit /// A `UIView` subclass that holds 3 dots which can be animated open class TypingIndicator: UIView { // MARK: Lifecycle public override init(frame: CGRect) { super.init(frame: frame) setupView() } public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setupView() } // MARK: Open /// A convenience accessor for the `backgroundColor` of each dot open var dotColor = UIColor.typingIndicatorDot { didSet { dots.forEach { $0.backgroundColor = dotColor } } } /// The `CABasicAnimation` applied when `isBounceEnabled` is TRUE to move the dot to the correct /// initial offset open var initialOffsetAnimationLayer: CABasicAnimation { let animation = CABasicAnimation(keyPath: "transform.translation.y") animation.byValue = -bounceOffset animation.duration = 0.5 animation.isRemovedOnCompletion = true return animation } /// The `CABasicAnimation` applied when `isBounceEnabled` is TRUE open var bounceAnimationLayer: CABasicAnimation { let animation = CABasicAnimation(keyPath: "transform.translation.y") animation.toValue = -bounceOffset animation.fromValue = bounceOffset animation.duration = 0.5 animation.repeatCount = .infinity animation.autoreverses = true return animation } /// The `CABasicAnimation` applied when `isFadeEnabled` is TRUE open var opacityAnimationLayer: CABasicAnimation { let animation = CABasicAnimation(keyPath: "opacity") animation.fromValue = 1 animation.toValue = 0.5 animation.duration = 0.5 animation.repeatCount = .infinity animation.autoreverses = true return animation } open override func layoutSubviews() { super.layoutSubviews() stackView.frame = bounds stackView.spacing = bounds.width > 0 ? 5 : 0 } // MARK: - Animation API /// Sets the state of the `TypingIndicator` to animating and applies animation layers open func startAnimating() { defer { isAnimating = true } guard !isAnimating else { return } var delay: TimeInterval = 0 for dot in dots { let currentDelay = delay DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } if self.isBounceEnabled { dot.layer.add(self.initialOffsetAnimationLayer, forKey: AnimationKeys.offset) let bounceLayer = self.bounceAnimationLayer bounceLayer.timeOffset = currentDelay + 0.33 dot.layer.add(bounceLayer, forKey: AnimationKeys.bounce) } if self.isFadeEnabled { dot.layer.add(self.opacityAnimationLayer, forKey: AnimationKeys.opacity) } } delay += 0.33 } } /// Sets the state of the `TypingIndicator` to not animating and removes animation layers open func stopAnimating() { defer { isAnimating = false } guard isAnimating else { return } dots.forEach { $0.layer.removeAnimation(forKey: AnimationKeys.bounce) $0.layer.removeAnimation(forKey: AnimationKeys.opacity) } } // MARK: Public /// The offset that each dot will transform by during the bounce animation public var bounceOffset: CGFloat = 2.5 /// A flag that determines if the bounce animation is added in `startAnimating()` public var isBounceEnabled = false /// A flag that determines if the opacity animation is added in `startAnimating()` public var isFadeEnabled = true /// A flag indicating the animation state public private(set) var isAnimating = false // MARK: - Subviews public let stackView = UIStackView() public let dots: [BubbleCircle] = { [BubbleCircle(), BubbleCircle(), BubbleCircle()] }() // MARK: Private /// Keys for each animation layer private enum AnimationKeys { static let offset = "typingIndicator.offset" static let bounce = "typingIndicator.bounce" static let opacity = "typingIndicator.opacity" } /// Sets up the view private func setupView() { dots.forEach { $0.backgroundColor = dotColor $0.heightAnchor.constraint(equalTo: $0.widthAnchor).isActive = true stackView.addArrangedSubview($0) } stackView.axis = .horizontal stackView.alignment = .center stackView.distribution = .fillEqually addSubview(stackView) } }