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

182 lines
6.7 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import SignalServiceKit
// Compare with CVColorOrGradientView:
//
// * 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 ColorOrGradientSwatchView: ManualLayoutViewWithLayer {
public var setting: ColorOrGradientSetting {
didSet {
if setting != oldValue {
configure()
}
}
}
public enum ShapeMode {
case circle
case rectangle
}
private let shapeMode: ShapeMode
private let themeMode: ColorOrGradientThemeMode
private let gradientLayer = CAGradientLayer()
public init(setting: ColorOrGradientSetting,
shapeMode: ShapeMode,
themeMode: ColorOrGradientThemeMode = .auto) {
self.setting = setting
self.shapeMode = shapeMode
self.themeMode = themeMode
var colorName: String?
switch setting {
case .solidColor(let color),
.themedColor(let color, _):
colorName = color.asUIColor.accessibilityName
case .gradient(let gradientColor1, let gradientColor2, _),
.themedGradient(let gradientColor1, let gradientColor2, _, _, _):
colorName = String(
format: OWSLocalizedString(
"WALLPAPER_GRADIENT_COLORS_ACCESSIBILITY_LABEL",
comment: "Accessibility label for gradient wallpaper swatch, naming the two colors in the gradient. {{ Embeds the names of the two colors in the gradient }}"
),
gradientColor1.asUIColor.accessibilityName,
gradientColor2.asUIColor.accessibilityName
)
}
super.init(name: colorName ?? "ColorOrGradientSwatchView")
self.shouldDeactivateConstraints = false
self.isAccessibilityElement = true
configure()
NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeDidChange, object: nil)
addLayoutBlock { view in
guard let view = view as? ColorOrGradientSwatchView else { return }
view.gradientLayer.frame = view.bounds
view.configure()
}
}
@objc
private func themeDidChange() {
configure()
}
fileprivate struct State: Equatable {
let size: CGSize
let setting: ColorOrGradientSetting
}
private var state: State?
private func configure() {
let size = bounds.size
let newState = State(size: size, setting: setting)
// Exit early if the appearance and bounds haven't changed.
guard state != newState else {
return
}
self.state = newState
switch shapeMode {
case .circle:
self.layer.cornerRadius = size.smallerAxis * 0.5
self.clipsToBounds = true
case .rectangle:
self.layer.cornerRadius = 0
self.clipsToBounds = false
}
switch setting.asValue(themeMode: themeMode) {
case .transparent:
backgroundColor = nil
gradientLayer.removeFromSuperlayer()
case .solidColor(let color):
backgroundColor = color
gradientLayer.removeFromSuperlayer()
case .gradient(let color1, let color2, let angleRadians):
backgroundColor = nil
if gradientLayer.superlayer != self.layer {
gradientLayer.removeFromSuperlayer()
layer.addSublayer(gradientLayer)
}
/* 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
switch shapeMode {
case .circle:
// In circle mode, we want the startPoint and endPoint to reside
// on the circumference of the circle.
startScale = 0.5
case .rectangle:
// 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
}
let startPointUL = unitCenter + startVector * +startScale
// The endpoint should be "opposite" the start point, on the opposite edge of the view.
let endPointUL = unitCenter + startVector * -startScale
// 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
}
let startPointLL = convertToLayerUnit(startPointUL)
let endPointLL = convertToLayerUnit(endPointUL)
CATransaction.begin()
CATransaction.setDisableActions(true)
gradientLayer.frame = self.bounds
gradientLayer.startPoint = startPointLL
gradientLayer.endPoint = endPointLL
gradientLayer.colors = [
color1.cgColor,
color2.cgColor
]
CATransaction.commit()
}
}
}