Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/views/TypingIndicatorView.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

class TypingIndicatorView: ManualStackView {
    // This represents the spacing between the dots
    // _at their max size_.
    private static let kDotMaxHSpacing: CGFloat = 3

    static let kMinRadiusPt: CGFloat = 6
    static let kMaxRadiusPt: CGFloat = 8

    private let dot1 = DotView(dotType: .dotType1)
    private let dot2 = DotView(dotType: .dotType2)
    private let dot3 = DotView(dotType: .dotType3)

    private var cachedMeasurement: ManualStackView.Measurement?

    init() {
        super.init(name: "TypingIndicatorView")
    }

    @available(*, unavailable, message: "use other constructor instead.")
    required init(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - Notifications

    @objc
    private func didBecomeActive() {
        AssertIsOnMainThread()

        // CoreAnimation animations are stopped in the background, so ensure
        // animations are restored if necessary.
        if isAnimating {
            startAnimation()
        }
    }

    // MARK: -

    func configureForChatList() {
        if let measurement = self.cachedMeasurement {
            self.configureForReuse(
                config: Self.stackConfig,
                measurement: measurement,
            )
        } else {
            let measurement = Self.measurement()
            self.cachedMeasurement = measurement
            self.configure(
                config: Self.stackConfig,
                measurement: measurement,
                subviews: [dot1, dot2, dot3],
            )
        }

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didBecomeActive),
            name: .OWSApplicationDidBecomeActive,
            object: nil,
        )
    }

    func configureForConversationView(cellMeasurement: CVCellMeasurement) {
        self.configure(
            config: Self.stackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_stack,
            subviews: [dot1, dot2, dot3],
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(didBecomeActive),
            name: .OWSApplicationDidBecomeActive,
            object: nil,
        )
    }

    private static var stackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .horizontal,
            alignment: .center,
            spacing: kDotMaxHSpacing,
            layoutMargins: .zero,
        )
    }

    private static let measurementKey_stack = "TypingIndicatorView.measurementKey_stack"

    static func measurement() -> ManualStackView.Measurement {
        let dotSize = CGSize.square(kMaxRadiusPt)
        let subviewInfos = [
            dotSize.asManualSubviewInfo(hasFixedSize: true),
            dotSize.asManualSubviewInfo(hasFixedSize: true),
            dotSize.asManualSubviewInfo(hasFixedSize: true),
        ]
        return ManualStackView.measure(config: stackConfig, subviewInfos: subviewInfos)
    }

    static func measure(measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
        let measurement = Self.measurement()
        measurementBuilder.setMeasurement(key: Self.measurementKey_stack, value: measurement)
        return measurement.measuredSize
    }

    override func reset() {
        super.reset()

        self.cachedMeasurement = nil

        stopAnimation()

        NotificationCenter.default.removeObserver(self)
    }

    func resetForReuse() {
        stopAnimation()

        NotificationCenter.default.removeObserver(self)
    }

    private func dots() -> [DotView] {
        return [dot1, dot2, dot3]
    }

    private var isAnimating = false

    func startAnimation() {
        isAnimating = true

        for dot in dots() {
            dot.startAnimation()
        }
    }

    func stopAnimation() {
        isAnimating = false

        for dot in dots() {
            dot.stopAnimation()
        }
    }

    private enum DotType {
        case dotType1
        case dotType2
        case dotType3
    }

    private class DotView: UIView {
        private let dotType: DotType

        private let shapeLayer = CAShapeLayer()

        @available(*, unavailable, message: "use other constructor instead.")
        required init?(coder aDecoder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        @available(*, unavailable, message: "use other constructor instead.")
        override init(frame: CGRect) {
            fatalError("init(frame:) has not been implemented")
        }

        init(dotType: DotType) {
            self.dotType = dotType

            super.init(frame: .zero)

            layer.addSublayer(shapeLayer)
        }

        fileprivate func startAnimation() {
            stopAnimation()

            let baseColor = Theme.secondaryTextAndIconColor
            let timeIncrement: CFTimeInterval = 0.15
            var colorValues = [CGColor]()
            var pathValues = [CGPath]()
            var keyTimes = [CFTimeInterval]()
            var animationDuration: CFTimeInterval = 0

            let addDotKeyFrame = { (keyFrameTime: CFTimeInterval, progress: CGFloat) in
                let dotColor = baseColor.withAlphaComponent(CGFloat.lerp(left: 0.4, right: 1.0, alpha: CGFloat.clamp01(progress)))
                colorValues.append(dotColor.cgColor)
                let radius = CGFloat.lerp(left: TypingIndicatorView.kMinRadiusPt, right: TypingIndicatorView.kMaxRadiusPt, alpha: CGFloat.clamp01(progress))
                let margin = (TypingIndicatorView.kMaxRadiusPt - radius) * 0.5
                let bezierPath = UIBezierPath(ovalIn: CGRect(x: margin, y: margin, width: radius, height: radius))
                pathValues.append(bezierPath.cgPath)

                keyTimes.append(keyFrameTime)
                animationDuration = max(animationDuration, keyFrameTime)
            }

            // All animations in the group apparently need to have the same number
            // of keyframes, and use the same timing.
            switch dotType {
            case .dotType1:
                addDotKeyFrame(0 * timeIncrement, 0.0)
                addDotKeyFrame(1 * timeIncrement, 0.5)
                addDotKeyFrame(2 * timeIncrement, 1.0)
                addDotKeyFrame(3 * timeIncrement, 0.5)
                addDotKeyFrame(4 * timeIncrement, 0.0)
                addDotKeyFrame(5 * timeIncrement, 0.0)
                addDotKeyFrame(6 * timeIncrement, 0.0)
                addDotKeyFrame(10 * timeIncrement, 0.0)
            case .dotType2:
                addDotKeyFrame(0 * timeIncrement, 0.0)
                addDotKeyFrame(1 * timeIncrement, 0.0)
                addDotKeyFrame(2 * timeIncrement, 0.5)
                addDotKeyFrame(3 * timeIncrement, 1.0)
                addDotKeyFrame(4 * timeIncrement, 0.5)
                addDotKeyFrame(5 * timeIncrement, 0.0)
                addDotKeyFrame(6 * timeIncrement, 0.0)
                addDotKeyFrame(10 * timeIncrement, 0.0)
            case .dotType3:
                addDotKeyFrame(0 * timeIncrement, 0.0)
                addDotKeyFrame(1 * timeIncrement, 0.0)
                addDotKeyFrame(2 * timeIncrement, 0.0)
                addDotKeyFrame(3 * timeIncrement, 0.5)
                addDotKeyFrame(4 * timeIncrement, 1.0)
                addDotKeyFrame(5 * timeIncrement, 0.5)
                addDotKeyFrame(6 * timeIncrement, 0.0)
                addDotKeyFrame(10 * timeIncrement, 0.0)
            }

            let makeAnimation: (String, [Any]) -> CAKeyframeAnimation = { keyPath, values in
                let animation = CAKeyframeAnimation()
                animation.keyPath = keyPath
                animation.values = values
                animation.duration = animationDuration
                return animation
            }

            let groupAnimation = CAAnimationGroup()
            groupAnimation.animations = [
                makeAnimation("fillColor", colorValues),
                makeAnimation("path", pathValues),
            ]
            groupAnimation.duration = animationDuration
            groupAnimation.repeatCount = MAXFLOAT

            shapeLayer.add(groupAnimation, forKey: UUID().uuidString)
        }

        fileprivate func stopAnimation() {
            shapeLayer.removeAllAnimations()
        }
    }
}