TM-SGNL-iOS/SignalUI/Views/OWSBubbleShapeView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

252 lines
8.5 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public struct OWSDirectionalRectCorner: OptionSet {
public let rawValue: Int8
public init(rawValue: Int8) {
self.rawValue = rawValue
}
public static let topLeading = OWSDirectionalRectCorner(rawValue: 1 << 0)
public static let topTrailing = OWSDirectionalRectCorner(rawValue: 1 << 1)
public static let bottomLeading = OWSDirectionalRectCorner(rawValue: 1 << 2)
public static let bottomTrailing = OWSDirectionalRectCorner(rawValue: 1 << 3)
public static let allCorners: OWSDirectionalRectCorner = [.topLeading, .topTrailing, .bottomLeading, .bottomTrailing]
}
public protocol OWSBubbleViewHost: AnyObject {
var maskPath: UIBezierPath { get }
var bubbleReferenceView: UIView { get }
}
public protocol OWSBubbleViewPartner: AnyObject {
func updateLayers()
func setBubbleViewHost(_ bubbleViewHost: OWSBubbleViewHost?)
}
// While rendering message bubbles, we often need to render
// into a subregion of the bubble that reflects the intersection
// of some subview (e.g. a media view) and the bubble shape
// (including its rounding).
public class OWSBubbleShapeView: UIView, OWSBubbleViewPartner {
// This view support multiple kinds of rendering.
public enum Mode: Equatable {
case stroke(strokeColor: UIColor, strokeThickness: CGFloat)
case fill(fillColor: UIColor)
case strokeAndFill(fillColor: UIColor, strokeColor: UIColor, strokeThickness: CGFloat)
// Casts a shadow over a subregion of the bubble shape.
case shadow(fillColor: UIColor?)
// Clipping subviews to subregion of the bubble shape.
case clip
case innerShadow(color: UIColor, radius: CGFloat, opacity: Float)
}
// If we ever make mode a var, we need to clear currentState
// in a didSet clause.
private let mode: Mode
private let shapeLayer = CAShapeLayer()
private let maskLayer = CAShapeLayer()
private weak var bubbleViewHost: OWSBubbleViewHost? {
didSet {
updateLayers()
}
}
private var isConfigured = false
public init(mode: Mode) {
self.mode = mode
super.init(frame: .zero)
self.isOpaque = false
self.backgroundColor = .clear
self.layoutMargins = .zero
owsAssertDebug(self.layer.delegate === self)
shapeLayer.disableAnimationsWithDelegate()
maskLayer.disableAnimationsWithDelegate()
layer.addSublayer(shapeLayer)
isConfigured = true
updateLayers()
}
@available(*, unavailable, message: "use other constructor instead.")
public required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func updateConstraints() {
super.updateConstraints()
deactivateAllConstraints()
}
public override var bounds: CGRect {
didSet {
if oldValue.size != bounds.size {
viewSizeDidChange()
}
}
}
public override var frame: CGRect {
didSet {
if oldValue.size != frame.size {
viewSizeDidChange()
}
}
}
private func viewSizeDidChange() {
updateLayers()
}
// MARK: - OWSBubbleViewPartner
public func setBubbleViewHost(_ bubbleViewHost: OWSBubbleViewHost?) {
self.bubbleViewHost = bubbleViewHost
}
public func updateLayers() {
guard isConfigured,
let bubbleViewHost = bubbleViewHost else {
return
}
// Add the bubble view's path to the local path.
let bubbleBezierPath: UIBezierPath = bubbleViewHost.maskPath
// We need to convert between coordinate systems using layers, not views.
let bubbleOffset: CGPoint = self.convert(CGPoint.zero, from: bubbleViewHost.bubbleReferenceView)
let newState = State(bounds: bounds,
bubbleOffset: bubbleOffset,
bubbleBezierPath: bubbleBezierPath)
guard newState != currentState else {
return
}
currentState = newState
Self.updateLayers(mode: mode,
state: newState,
bubbleShapeView: self)
}
private struct State: Equatable {
let bounds: CGRect
let bubbleOffset: CGPoint
let bubbleBezierPath: UIBezierPath
}
private var currentState: State?
private static func updateLayers(mode: Mode,
state: State,
bubbleShapeView: OWSBubbleShapeView) {
let shapeLayer = bubbleShapeView.shapeLayer
let maskLayer = bubbleShapeView.maskLayer
let bounds = state.bounds
let bubbleOffset = state.bubbleOffset
let bubbleBezierPath = state.bubbleBezierPath
let bezierPath = UIBezierPath()
let transform: CGAffineTransform = CGAffineTransform.translate(bubbleOffset)
bubbleBezierPath.apply(transform)
bezierPath.append(bubbleBezierPath)
func configureForDrawing(fillColor: UIColor? = nil,
strokeColor: UIColor? = nil,
strokeThickness: CGFloat = 0) {
bezierPath.append(UIBezierPath(rect: bounds))
bubbleShapeView.clipsToBounds = true
if let strokeColor = strokeColor {
shapeLayer.strokeColor = strokeColor.cgColor
shapeLayer.lineWidth = strokeThickness
shapeLayer.zPosition = 100
} else {
shapeLayer.strokeColor = nil
shapeLayer.lineWidth = 0
}
if let fillColor = fillColor {
shapeLayer.fillColor = fillColor.cgColor
} else {
shapeLayer.fillColor = nil
}
shapeLayer.path = bezierPath.cgPath
}
switch mode {
case .stroke(let strokeColor, let strokeThickness):
configureForDrawing(strokeColor: strokeColor, strokeThickness: strokeThickness)
case .fill(let fillColor):
configureForDrawing(fillColor: fillColor)
case .strokeAndFill(let fillColor, let strokeColor, let strokeThickness):
configureForDrawing(fillColor: fillColor, strokeColor: strokeColor, strokeThickness: strokeThickness)
case .shadow(let fillColor):
bubbleShapeView.clipsToBounds = false
if let fillColor = fillColor {
shapeLayer.fillColor = fillColor.cgColor
} else {
shapeLayer.fillColor = nil
}
shapeLayer.path = bezierPath.cgPath
shapeLayer.frame = bounds
shapeLayer.masksToBounds = true
shapeLayer.shadowPath = bezierPath.cgPath
case .clip:
maskLayer.path = bezierPath.cgPath
bubbleShapeView.layer.mask = maskLayer
case .innerShadow(let color, let radius, let opacity):
// Inner shadow.
// This should usually not be visible; it is used to distinguish
// profile pics from the background if they are similar.
shapeLayer.frame = bounds
shapeLayer.masksToBounds = true
let shadowBounds = bounds
let shadowPath = bezierPath.copy() as! UIBezierPath
// This can be any value large enough to cast a sufficiently large shadow.
let shadowInset = radius * -4
let outerRect = shadowBounds.inset(by: UIEdgeInsets(hMargin: shadowInset,
vMargin: shadowInset))
let outerPath = UIBezierPath(rect: outerRect)
// -[CALayer shadowPath] uses the non-zero winding rule
// Reverse the path to make the directions line up correctly.
shadowPath.append(outerPath.reversing())
// This can be any color since the fill should be clipped.
shapeLayer.fillColor = UIColor.black.cgColor
shapeLayer.path = shadowPath.cgPath
shapeLayer.shadowColor = color.cgColor
shapeLayer.shadowRadius = radius
shapeLayer.shadowOpacity = opacity
shapeLayer.shadowOffset = .zero
shapeLayer.shadowPath = shadowPath.cgPath
}
}
// MARK: - CALayerDelegate
public override func action(for layer: CALayer, forKey event: String) -> CAAction? {
// Disable all implicit CALayer animations.
NSNull()
}
}