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

import SignalServiceKit
public import SignalUI

// Compare with ColorOrGradientSwatchView:
//
// * CVColorOrGradientView is intended to be used in CVC cells.
//   It does not assume the gradient bounds corresponds to the
//   view bounds.
// * ColorOrGradientSwatchView is for use elsewhere.
//   It can pin gradient bounds to the edges of a circle for previews.
//   It can be used to render wallpapers.
//
// Although we could combine these two views, these two scenarios are
// just different enough that its convenient to have two separate views.
public class CVColorOrGradientView: ManualLayoutViewWithLayer, CVDimmableView {

    private weak var referenceView: UIView?
    private var value: ColorOrGradientValue?

    private var bubbleConfig: BubbleConfiguration?

    private var backgroundBlurView: UIVisualEffectView?
    private let gradientLayer = CAGradientLayer()
    private let strokeLayer = CAShapeLayer()
    private let maskLayer = CAShapeLayer()
    private var dimmerLayer: CALayer?

    public var ensureSubviewsFillBounds = false
    public var animationsEnabled = false

    public init() {
        super.init(name: "CVColorOrGradientView")

        strokeLayer.fillColor = nil

        gradientLayer.disableAnimationsWithDelegate()
        strokeLayer.disableAnimationsWithDelegate()
        maskLayer.disableAnimationsWithDelegate()
    }

    private func addDefaultLayoutBlock() {
        addLayoutBlock { view in
            guard let view = view as? CVColorOrGradientView else { return }
            view.updateAppearance()
        }
    }

    private func ensureSubviewLayout() {
        guard ensureSubviewsFillBounds else { return }
        for subview in subviews {
            ManualLayoutView.setSubviewFrame(subview: subview, frame: bounds)
        }
    }

    public func configure(
        value: ColorOrGradientValue,
        referenceView: UIView,
        bubbleConfig: BubbleConfiguration? = nil,
    ) {
        self.value = value
        self.referenceView = referenceView
        self.bubbleConfig = bubbleConfig

        addDefaultLayoutBlock()

        updateAppearance()
    }

    public func updateAppearance() {

        guard let value, let referenceView else {
            backgroundColor = nil
            backgroundBlurView?.removeFromSuperview()
            gradientLayer.removeFromSuperlayer()
            dimmerLayer?.removeFromSuperlayer()
            return
        }

        switch value {
        case .transparent:
            backgroundColor = nil
            backgroundBlurView?.removeFromSuperview()
            gradientLayer.removeFromSuperlayer()
            dimmerLayer?.removeFromSuperlayer()

        case .blur(let blurEffect):
            backgroundColor = nil
            if let backgroundBlurView {
                backgroundBlurView.effect = blurEffect
                // `backgroundBlurView` will be removed as a subview if `reset()` was called.
                // But not every call of `updateAppearance()` is preceded by `reset()`.
                if backgroundBlurView.superview != self {
                    addSubviewToFillSuperviewEdges(backgroundBlurView)
                }
            } else {
                let backgroundBlurView = UIVisualEffectView(effect: blurEffect)
                addSubviewToFillSuperviewEdges(backgroundBlurView)
                self.backgroundBlurView = backgroundBlurView
            }
            gradientLayer.removeFromSuperlayer()

        case .solidColor(let color):
            backgroundColor = color
            backgroundBlurView?.removeFromSuperview()
            gradientLayer.removeFromSuperlayer()

        case .gradient(let color1, let color2, let angleRadians):
            backgroundBlurView?.removeFromSuperview()

            if gradientLayer.superlayer != self.layer {
                gradientLayer.removeFromSuperlayer()
                layer.insertSublayer(gradientLayer, at: 0)
            }

            gradientLayer.frame = self.bounds

            gradientLayer.colors = [
                color1.cgColor,
                color2.cgColor,
            ]

            /* The start and end points of the gradient when drawn into the layer's
             * coordinate space. The start point corresponds to the first gradient
             * stop, the end point to the last gradient stop. Both points are
             * defined in a unit coordinate space that is then mapped to the
             * layer's bounds rectangle when drawn. (I.e. [0,0] is the bottom-left
             * corner of the layer, [1,1] is the top-right corner.) The default values
             * are [.5,0] and [.5,1] respectively. Both are animatable. */
            let unitCenter = CGPoint(x: 0.5, y: 0.5)
            // Note the signs.
            let startVector = CGPoint(x: +sin(angleRadians), y: -cos(angleRadians))
            let startScale: CGFloat
            // In rectangle mode, we want the startPoint and endPoint to reside
            // on the edge of the unit square, and thus edge of the rectangle.
            // We therefore scale such that longer axis is a half unit.
            let startSquareScale: CGFloat = max(abs(startVector.x), abs(startVector.y))
            startScale = 0.5 / startSquareScale

            // Control points within the bounding box of the entire gradient.
            // Expressed as unit values with upper-left origin.
            //
            // 0,0
            // ********************** C1 **
            // *                          *
            // *                          *
            // *                          *
            // *                          *
            // *                          *
            // *                          *
            // ** C2 ********************** 1,1
            //
            let startPointGradientUnitsUL = unitCenter + startVector * +startScale
            // The endpoint should be "opposite" the start point,
            // on the opposite edge of the gradient.
            let endPointGradientUnitsUL = unitCenter + startVector * -startScale

            // Each message bubble renders a subsection of the gradient.
            // We need to convert the control points from the bounding box
            // of the gradient to the local unit coordinate space of this view.
            // The reference frame (bounding box of the entire gradient)
            // in local points.
            let referenceFrameLocalPoints = self.convert(referenceView.bounds, from: referenceView)
            // The reference frame (bounding box of the entire gradient)
            // in local unit coordinates.
            let referenceFrameLocalUnits = CGRect(
                x: referenceFrameLocalPoints.x.inverseLerp(bounds.minX, bounds.maxX),
                y: referenceFrameLocalPoints.y.inverseLerp(bounds.minY, bounds.maxY),
                width: referenceFrameLocalPoints.width / bounds.width,
                height: referenceFrameLocalPoints.height / bounds.height,
            )
            func convertFromGradientToLocal(_ point: CGPoint) -> CGPoint {
                CGPoint(
                    x: point.x.lerp(referenceFrameLocalUnits.minX, referenceFrameLocalUnits.maxX),
                    y: point.y.lerp(referenceFrameLocalUnits.minY, referenceFrameLocalUnits.maxY),
                )
            }
            // Control points within the local UIView viewport.
            // Expressed as unit values with upper-left origin.
            //
            // ********************** C1 **
            // *                          *
            // *            0,0           *
            // *            ********      *
            // *            *      *      *
            // *            ******** 1,1  *
            // *                          *
            // ** C2 **********************
            //
            let startPointViewportUnitsUL = convertFromGradientToLocal(startPointGradientUnitsUL)
            let endPointViewportUnitsUL = convertFromGradientToLocal(endPointGradientUnitsUL)

            // UIKit/UIView uses an upper-left origin.
            // Core Graphics/CALayer uses a lower-left origin.
            func convertToLayerUnit(_ point: CGPoint) -> CGPoint {
                // TODO: The documentation clearly indicates that
                // CAGradientLayer.startPoint and endPoint use the layer's
                // coordinate space with lower-left origin.  But the
                // observed behavior is that they use an upper-left origin.
                // I can't figure out why.
                // return CGPoint(x: point.x, y: (1 - point.y))
                return point
            }
            // Control points within the local CALayer viewport.
            // Expressed as unit values with lower-left origin.
            //
            // ********************** C1 **
            // *                          *
            // *                          *
            // *            ******** 1,1  *
            // *            *      *      *
            // *        0,0 ********      *
            // *                          *
            // ** C2 **********************
            //
            let startPointLayerUnitsLL = convertToLayerUnit(startPointViewportUnitsUL)
            let endPointLayerUnitsLL = convertToLayerUnit(endPointViewportUnitsUL)

            gradientLayer.startPoint = startPointLayerUnitsLL
            gradientLayer.endPoint = endPointLayerUnitsLL
        }

        // Bubble shape.
        if let bubbleConfig {
            // Corners.
            maskLayer.path = bubbleConfig.bubblePath(for: bounds).cgPath
            layer.mask = maskLayer

            // Stroke.
            if
                let stroke = bubbleConfig.stroke,
                let strokePath = bubbleConfig.strokePath(for: bounds)
            {
                strokeLayer.lineWidth = stroke.width
                strokeLayer.strokeColor = stroke.color.cgColor
                strokeLayer.path = strokePath.cgPath
                layer.addSublayer(strokeLayer)
            } else {
                strokeLayer.removeFromSuperlayer()
            }
        } else {
            layer.mask = nil

            strokeLayer.removeFromSuperlayer()
        }

        ensureSubviewLayout()
    }

    override public func reset() {
        super.reset()

        referenceView = nil
        value = nil
        backgroundColor = nil
        bubbleConfig = nil
        strokeLayer.removeFromSuperlayer()
        gradientLayer.removeFromSuperlayer()
        dimmerLayer?.removeFromSuperlayer()
    }

    // MARK: - CALayerDelegate

    override public func action(for layer: CALayer, forKey event: String) -> CAAction? {
        // Disable all implicit CALayer animations if needed
        if animationsEnabled {
            return super.action(for: layer, forKey: event)
        } else {
            return NSNull()
        }

    }

    // MARK: - CVDimmableView

    var dimmerColor: UIColor = .clear

    var dimsContent = false

    var backgroundLayer: CALayer? { gradientLayer }
}

// MARK: -

extension CVColorOrGradientView: OWSBubbleViewHost {

    public var maskPath: UIBezierPath {
        guard let bubbleConfig else {
            return UIBezierPath(rect: bounds)
        }
        return bubbleConfig.bubblePath(for: bounds)
    }

    public var bubbleReferenceView: UIView { self }
}