// // Copyright 2026 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import UIKit public class SelectionIndicatorView: UIView { public enum Style { /// Use in lists over plain colored background. case list /// Use over media. case media } // MARK: UIView public init(style: Style = .list) { self.style = style super.init(frame: .init(origin: .zero, size: .square(SelectionIndicatorView.preferredSize))) // Because it is often paired with UILabels, we want to make // this view as compact and as compression resistant as possible. setContentHuggingPriority(.defaultHigh, for: .horizontal) setContentHuggingPriority(.defaultHigh, for: .vertical) setContentCompressionResistancePriority(.required - 10, for: .horizontal) setContentCompressionResistancePriority(.required - 10, for: .vertical) switch style { case .list: addSubview(innerRing) case .media: addSubview(outerRing) } addSubview(selectedView) updateAppearance(animated: false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: Layout // For `MessageSelectionView` to use. public static let preferredSize: CGFloat = 24 private static let ringStrokeWidth: CGFloat = 2 private static let innerRingInset: CGFloat = 1 override public var intrinsicContentSize: CGSize { .square(Self.preferredSize) } override public func layoutSubviews() { super.layoutSubviews() switch style { case .list: innerRing.center = bounds.center // Inner ring is inset by 1dp relative to the view's bounds. // Filled circle (checkmark's background) has the same diameter as inner ring. let circleDiameter = Self.preferredSize - 2 * Self.innerRingInset innerRing.bounds.size = .square(circleDiameter) selectedView.bounds.size = .square(circleDiameter) case .media: outerRing.center = bounds.center outerRing.bounds.size = .square(Self.preferredSize) // Filled circle (checkmark's background) fills inside of the outer ring. selectedView.bounds.size = .square(Self.preferredSize - 2 * Self.ringStrokeWidth) } selectedView.center = bounds.center // Checkmark is self-sized and only needs to be centered properly. checkmarkIcon.center = selectedView.bounds.center } // MARK: State private var _isSelected: Bool = false public var isSelected: Bool { get { _isSelected } set { setIsSelected(newValue, animated: false) } } public func setIsSelected(_ isSelected: Bool, animated: Bool) { guard isSelected != _isSelected else { return } _isSelected = isSelected updateAppearance(animated: animated) } private var _isEnabled: Bool = true public var isEnabled: Bool { get { _isEnabled } set { setIsEnabled(newValue, animated: false) } } public func setIsEnabled(_ isEnabled: Bool, animated: Bool) { guard isEnabled != _isEnabled else { return } _isEnabled = isEnabled updateAppearance(animated: animated) } // Make this a `let` to simplify layout and avoid overhead of creating unused views. // The assumption is to only reference `innerRing` when style is `list` // and only reference `outerRing` when style is `media`. public let style: Style // MARK: Appearance /// Color that fills the selection ring and is the background for checkmark image. Defaut is `UIColor.Signal.accent`. public var fillColor: UIColor = .Signal.accent { didSet { selectedView.backgroundColor = fillColor } } private var effectiveFillColor: UIColor { isEnabled ? fillColor : unselectedListIndicatorColor } /// Color for the checkmark symbol and outer ring when `style` is `media`. Default is `white`. public var strokeColor: UIColor = .white { didSet { checkmarkIcon.tintColor = strokeColor if case .media = style { outerRing.tintColor = strokeColor } } } private var _unselectedColor: UIColor = .Signal.tertiaryLabel /// Colors of the emty circle when `style` is `list`. Defaults to `UIColor.Signal.tertiaryLabel`. public var unselectedListIndicatorColor: UIColor! { get { _unselectedColor } set { owsAssertDebug(style == .list, "Invalid access") _unselectedColor = newValue ?? .Signal.tertiaryLabel if case .list = style { innerRing.tintColor = unselectedListIndicatorColor } } } private lazy var innerRing: UIView = { owsAssertDebug(style == .list, "Invalid access") let ringView = RingView() ringView.lineWidth = SelectionIndicatorView.ringStrokeWidth ringView.tintColor = unselectedListIndicatorColor return ringView }() private lazy var outerRing: UIView = { owsAssertDebug(style == .media, "Invalid access") let ringView = RingView() ringView.lineWidth = SelectionIndicatorView.ringStrokeWidth ringView.tintColor = strokeColor return ringView }() private lazy var selectedView: UIView = { let circleView = CircleView() circleView.backgroundColor = effectiveFillColor circleView.addSubview(checkmarkIcon) return circleView }() private lazy var checkmarkIcon: UIImageView = { let imageView = UIImageView(image: UIImage(named: "check-compact")) imageView.contentMode = .scaleAspectFit imageView.tintColor = strokeColor return imageView }() private func updateAppearance(animated: Bool) { if case .list = style { innerRing.setIsHidden(isSelected, animated: animated) } // Outer ring is always visible. selectedView.setIsHidden(isSelected == false, animated: animated) selectedView.backgroundColor = effectiveFillColor } }