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

import SignalServiceKit
import UIKit

class MediaTransitionImageView: UIImageView {

    var shape: MediaViewShape = .rectangle(0) {
        willSet {
            guard layer.mask == nil, case .variableRoundedCorners(let roundedCorners) = newValue else { return }

            // Only use masking layer if desired appearance cannot be achieved
            // by modifying `CALayer.cornerRadius` and `CALayer.maskedCorners`,
            // ie there is more than one corner radius being used.
            // Prepare by setting up a masking layer that, once created, will be used exclusively.
            if roundedCorners.groupedNonZeroCorners().keys.count > 1 {
                let maskLayer = CAShapeLayer()
                updateMaskLayer(maskLayer)
                layer.mask = maskLayer
            }
        }
        didSet {
            // Layer needs to be updated on bounds/frame change if:
            // * shape is circle - corner radius is a function of layer's size.
            // * masking layer is being used.
            switch shape {
            case .circle: updateShapeOnBoundsChange = true
            default: updateShapeOnBoundsChange = layer.mask != nil
            }

            if !updateShapeOnBoundsChange {
                updateShape()
            }
        }
    }

    private var updateShapeOnBoundsChange = false

    override var bounds: CGRect {
        didSet {
            if updateShapeOnBoundsChange {
                updateShape()
            }
        }
    }

    override var frame: CGRect {
        didSet {
            if updateShapeOnBoundsChange {
                updateShape()
            }
        }
    }

    private func updateShape() {
        // If there is a masking layer - use that to create shape.
        // Masking layer would be created in `shape.willSet` for `variableRoundedCorners` case.
        if let maskLayer = layer.mask as? CAShapeLayer {
            layer.cornerRadius = 0
            layer.maskedCorners = .all

            // Unfortunately masking layer's path is not implicitly animatable.
            // The workaround is to grab animation attached to view's layer,
            // copy it and use for animating masking path.
            let maskAnimation: CABasicAnimation?
            if let animation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
                maskAnimation = animation.copy() as? CABasicAnimation
            } else {
                maskAnimation = nil
            }
            updateMaskLayer(maskLayer, using: maskAnimation)
            return
        }

        // No masking layer means simpler cases of corner rounding - use CALayer.cornerRadius.
        // Note that we don't try and find an attached animation because `CALayer.cornerRadius`
        // is an implicitly animatable property.
        switch shape {
        case .rectangle(let cornerRadius):
            layer.cornerRadius = cornerRadius
            if cornerRadius > 0 {
                // CoreAnimation doesn't properly animate transition from some rounded
                // corners to all rounded corners - change below would take affect immediately.
                // Luckily for us media is always animated from some (or all) rounded corners
                // to no rounded corners (and back) which means `maskedCorners` can stay whatever it was.
                layer.maskedCorners = .all
            }

        case .circle:
            layer.cornerRadius = 0.5 * min(layer.bounds.width, layer.bounds.height)
            layer.maskedCorners = .all

        case .variableRoundedCorners(let roundedCorners):
            let roundedCornersGroupedByRadius = roundedCorners.groupedNonZeroCorners()
            owsAssertBeta(roundedCornersGroupedByRadius.count == 1)
            guard let cornerInfo = roundedCornersGroupedByRadius.first else { return }
            layer.cornerRadius = cornerInfo.key
            layer.maskedCorners = cornerInfo.value
        }

    }

    private func updateMaskLayer(_ maskLayer: CAShapeLayer, using animation: CABasicAnimation? = nil) {
        maskLayer.frame = layer.bounds

        let roundedCorners: RoundedCorners = {
            switch shape {
            case .circle:
                return .all(0.5 * min(maskLayer.bounds.width, maskLayer.bounds.height))

            case .rectangle(let cornerRadius):
                return .all(cornerRadius)

            case .variableRoundedCorners(let roundedCorners):
                return roundedCorners
            }
        }()

        // Note that path must be constructed using the same method even if there are no rounded corners,
        // otherwise animation would be incorrect.
        let maskPath = UIBezierPath.roundedRect(
            maskLayer.bounds,
            topLeftRounding: roundedCorners.topLeft,
            topRightRounding: roundedCorners.topRight,
            bottomRightRounding: roundedCorners.bottomRight,
            bottomLeftRounding: roundedCorners.bottomLeft,
        )

        if let animation {
            animation.keyPath = "path"
            animation.fromValue = maskLayer.path
            animation.toValue = maskPath.cgPath
            maskLayer.add(animation, forKey: "path")
            maskLayer.path = maskPath.cgPath
        } else {
            maskLayer.path = maskPath.cgPath
        }
    }
}

private extension RoundedCorners {

    func groupedNonZeroCorners() -> [CGFloat: CACornerMask] {
        var result = [CGFloat: CACornerMask]()
        let populateResult: (CGFloat, CACornerMask) -> Void = { cornerRadius, corner in
            guard cornerRadius > 0 else { return }
            if var cornerMask = result[cornerRadius] {
                cornerMask.insert(corner)
                result[cornerRadius] = cornerMask
            } else {
                result[cornerRadius] = [corner]
            }
        }
        populateResult(topLeft, .layerMinXMinYCorner)
        populateResult(topRight, .layerMaxXMinYCorner)
        populateResult(bottomRight, .layerMaxXMaxYCorner)
        populateResult(bottomLeft, .layerMinXMaxYCorner)
        return result
    }
}