diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 82f8e71698..9206cfa4c5 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -504,7 +504,6 @@ 45069FC629D3A7C800D0DD14 /* WideMediaTileViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45069FC529D3A7C800D0DD14 /* WideMediaTileViewLayout.swift */; }; 45069FC829D3A7E700D0DD14 /* SquareMediaTileViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45069FC729D3A7E700D0DD14 /* SquareMediaTileViewLayout.swift */; }; 45069FCA29D4FFBB00D0DD14 /* MediaTileDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45069FC929D4FFBB00D0DD14 /* MediaTileDateFormatter.swift */; }; - 45069FCE29D64CB300D0DD14 /* MediaSelectionIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45069FCD29D64CB300D0DD14 /* MediaSelectionIndicatorView.swift */; }; 450B0FC929FB301700B9A458 /* AudioMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450B0FC829FB301700B9A458 /* AudioMessageView.swift */; }; 45161BA928A2E54B0055AB45 /* ThreadReplyInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45161BA828A2E54B0055AB45 /* ThreadReplyInfo.swift */; }; 4520D8D51D417D8E00123472 /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4520D8D41D417D8E00123472 /* Photos.framework */; }; @@ -1518,7 +1517,7 @@ 768FC7B42F316CF800707F72 /* CVDimmableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768FC7B32F316CEF00707F72 /* CVDimmableView.swift */; }; 768FC7B62F32A0B000707F72 /* UIImage+Blur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768FC7B52F32A0AB00707F72 /* UIImage+Blur.swift */; }; 7694C9172FB51AA4006E727F /* DisappearingMessagesChatIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7694C9162FB51AA4006E727F /* DisappearingMessagesChatIndicatorView.swift */; }; - 76964AFB2FB4035D007130BE /* ListItemSelectionIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76964AFA2FB4034C007130BE /* ListItemSelectionIndicatorView.swift */; }; + 76964AFB2FB4035D007130BE /* SelectionIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76964AFA2FB4034C007130BE /* SelectionIndicatorView.swift */; }; 76995F15283868BD009DD4F4 /* ImageEditorViewController+StrokeWidthSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76995F14283868BD009DD4F4 /* ImageEditorViewController+StrokeWidthSlider.swift */; }; 76A066EA2EA99D1F009B3ED5 /* ConversationInputToolbar+VoiceMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76A066E92EA99D0E009B3ED5 /* ConversationInputToolbar+VoiceMemo.swift */; }; 76A2EB1028B578B800A29C24 /* MediaTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76A2EB0F28B578B800A29C24 /* MediaTextView.swift */; }; @@ -4717,7 +4716,6 @@ 45069FC529D3A7C800D0DD14 /* WideMediaTileViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WideMediaTileViewLayout.swift; sourceTree = ""; }; 45069FC729D3A7E700D0DD14 /* SquareMediaTileViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquareMediaTileViewLayout.swift; sourceTree = ""; }; 45069FC929D4FFBB00D0DD14 /* MediaTileDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTileDateFormatter.swift; sourceTree = ""; }; - 45069FCD29D64CB300D0DD14 /* MediaSelectionIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSelectionIndicatorView.swift; sourceTree = ""; }; 450B0FC829FB301700B9A458 /* AudioMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessageView.swift; sourceTree = ""; }; 45161BA828A2E54B0055AB45 /* ThreadReplyInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadReplyInfo.swift; sourceTree = ""; }; 451764291DE939FD00EDB8B9 /* ContactCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactCell.swift; sourceTree = ""; }; @@ -5720,7 +5718,7 @@ 768FC7B32F316CEF00707F72 /* CVDimmableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVDimmableView.swift; sourceTree = ""; }; 768FC7B52F32A0AB00707F72 /* UIImage+Blur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Blur.swift"; sourceTree = ""; }; 7694C9162FB51AA4006E727F /* DisappearingMessagesChatIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessagesChatIndicatorView.swift; sourceTree = ""; }; - 76964AFA2FB4034C007130BE /* ListItemSelectionIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemSelectionIndicatorView.swift; sourceTree = ""; }; + 76964AFA2FB4034C007130BE /* SelectionIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionIndicatorView.swift; sourceTree = ""; }; 76995F14283868BD009DD4F4 /* ImageEditorViewController+StrokeWidthSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageEditorViewController+StrokeWidthSlider.swift"; sourceTree = ""; }; 76A066E92EA99D0E009B3ED5 /* ConversationInputToolbar+VoiceMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationInputToolbar+VoiceMemo.swift"; sourceTree = ""; }; 76A2EB0F28B578B800A29C24 /* MediaTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTextView.swift; sourceTree = ""; }; @@ -9139,7 +9137,6 @@ 34A95546271B510400B05242 /* GradientView.swift */, 66F98DE52DBBED68009F1A86 /* LineWrappingStackView.swift */, 34A95529271B510400B05242 /* LinkingTextView.swift */, - 76964AFA2FB4034C007130BE /* ListItemSelectionIndicatorView.swift */, 34A9552F271B510400B05242 /* LoopingVideoView.swift */, 34A9551D271B510400B05242 /* ManualLayoutView.swift */, 34A9550C271B510400B05242 /* ManualStackView.swift */, @@ -9160,6 +9157,7 @@ 45A6DAD51EBBF85500893231 /* ReminderView.swift */, 763D7DDA27E155ED002EA7E6 /* RoundMediaButton.swift */, 766BCA7C29FB049400046016 /* RTLEnabledCollectionViewFlowLayout.swift */, + 76964AFA2FB4034C007130BE /* SelectionIndicatorView.swift */, 885275BF27E26775003F2F9B /* TextAttachmentView.swift */, 764FE0402A2EF3A7004D2804 /* TextFieldFormatting.swift */, 34A9553E271B510400B05242 /* TextViewWithPlaceholder.swift */, @@ -11169,7 +11167,6 @@ 32AC5CE6255B51E900829BD8 /* JoinGroupCallPill.swift */, 88A941982409A391000E9700 /* LottieToggleButton.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, - 45069FCD29D64CB300D0DD14 /* MediaSelectionIndicatorView.swift */, 45069FC929D4FFBB00D0DD14 /* MediaTileDateFormatter.swift */, 8829883A25B9FD6700DEE1E3 /* MockConversationView.swift */, 3236FCC32592B67B006D33B9 /* NameCollisionReviewCell.swift */, @@ -17857,7 +17854,6 @@ 5003BB3F299DA0F10037159B /* LinkPreviewFetchState.swift in Sources */, 3402AA77271D9E180084CBAE /* LinkPreviewState.swift in Sources */, 3402AA6D271D9E180084CBAE /* LinkPreviewView.swift in Sources */, - 76964AFB2FB4035D007130BE /* ListItemSelectionIndicatorView.swift in Sources */, 3402AA8C271D9E180084CBAE /* LoopingVideoView.swift in Sources */, 3402AA9C271D9E180084CBAE /* ManualLayoutView.swift in Sources */, 3402AA8D271D9E180084CBAE /* ManualStackView.swift in Sources */, @@ -17932,6 +17928,7 @@ B9488E752CDED27200C1294B /* ScrollOffset.swift in Sources */, 50597BBF2B97D629004681E1 /* SearchableNameFinder.swift in Sources */, 66FC638E29EDABAC00F00DAC /* SearchDisplayConfigurations.swift in Sources */, + 76964AFB2FB4035D007130BE /* SelectionIndicatorView.swift in Sources */, 66FBC4E328DA82AA00BD9E8B /* SelectMyStoryRecipientsViewController.swift in Sources */, 504861AA2EEB4D0D00B13C49 /* SendableAttachment.swift in Sources */, D9791BB92EAA84830016AA5A /* SheetDisplayableError.swift in Sources */, @@ -18537,7 +18534,6 @@ 76BB06FA29AD84DB00978856 /* MediaItemViewController.swift in Sources */, 45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */, 4CD675C722E7D393008010D2 /* MediaPresentationContext.swift in Sources */, - 45069FCE29D64CB300D0DD14 /* MediaSelectionIndicatorView.swift in Sources */, 45069FCA29D4FFBB00D0DD14 /* MediaTileDateFormatter.swift in Sources */, 45D9784229F0B50000BBB3C0 /* MediaTileListModeCell.swift in Sources */, 45CADA8B298DD2B4009EBDF5 /* MediaTileScrollFlag.swift in Sources */, diff --git a/Signal/src/ViewControllers/MediaGallery/Cells/MediaTileListModeCell.swift b/Signal/src/ViewControllers/MediaGallery/Cells/MediaTileListModeCell.swift index 1130413041..281b22e932 100644 --- a/Signal/src/ViewControllers/MediaGallery/Cells/MediaTileListModeCell.swift +++ b/Signal/src/ViewControllers/MediaGallery/Cells/MediaTileListModeCell.swift @@ -29,7 +29,7 @@ class MediaTileListModeCell: UICollectionViewCell, MediaGalleryCollectionViewCel return view }() - let selectionButton = ListItemSelectionIndicatorView() + let selectionButton = SelectionIndicatorView() private let selectedMaskView = UIView() diff --git a/Signal/src/views/MediaSelectionIndicatorView.swift b/Signal/src/views/MediaSelectionIndicatorView.swift deleted file mode 100644 index 74a3bf74ce..0000000000 --- a/Signal/src/views/MediaSelectionIndicatorView.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// Copyright 2023 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import SignalUI -import UIKit - -/// A checkmark in a circle to indicate an item (typically in a table view or collection view) is -/// selected. -class MediaSelectionIndicatorView: UIView { - private let outlineBadgeView: UIView = { - let imageView = UIImageView(image: UIImage(imageLiteralResourceName: "circle")) - imageView.contentMode = .center - imageView.tintColor = .white - imageView.isHidden = true - return imageView - }() - - private let selectedBadgeView: UIView = { - let imageView = UIImageView(image: Theme.iconImage(.checkCircleFill)) - imageView.contentMode = .center - imageView.tintColor = .ows_accentBlue - - // This will give checkmark it's color. - let backgroundView = CircleView(diameter: 18) - backgroundView.backgroundColor = .white - - let containerView = UIView(frame: imageView.bounds) - containerView.isHidden = true - - containerView.addSubview(backgroundView) - backgroundView.autoCenterInSuperview() - - containerView.addSubview(imageView) - imageView.autoPinEdgesToSuperviewEdges() - - return containerView - }() - - var isSelected: Bool = false { - didSet { - updateAppearance() - } - } - - var allowsMultipleSelection: Bool = false { - didSet { - updateAppearance() - } - } - - var outlineColor: UIColor = .white { - didSet { - outlineBadgeView.tintColor = outlineColor - } - } - - var hidesOutlineWhenSelected: Bool = false { - didSet { - updateAppearance() - } - } - - init() { - super.init(frame: .zero) - - addSubview(selectedBadgeView) - selectedBadgeView.autoCenterInSuperview() - - addSubview(outlineBadgeView) - outlineBadgeView.autoCenterInSuperview() - - autoSetDimensions(to: .square(24)) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func updateAppearance() { - if isSelected { - outlineBadgeView.isHidden = hidesOutlineWhenSelected - selectedBadgeView.isHidden = false - } else if allowsMultipleSelection { - outlineBadgeView.isHidden = false - selectedBadgeView.isHidden = true - } else { - outlineBadgeView.isHidden = true - selectedBadgeView.isHidden = true - } - } - - func reset() { - selectedBadgeView.isHidden = true - outlineBadgeView.isHidden = true - } -} diff --git a/Signal/src/views/PhotoGridViewCell.swift b/Signal/src/views/PhotoGridViewCell.swift index ef5bb6fa33..145743347b 100644 --- a/Signal/src/views/PhotoGridViewCell.swift +++ b/Signal/src/views/PhotoGridViewCell.swift @@ -69,7 +69,7 @@ class PhotoGridViewCell: UICollectionViewCell { private var durationLabel: UILabel? private var durationLabelBackground: UIView? - private let selectionButton = MediaSelectionIndicatorView() + private let selectionIndicator = SelectionIndicatorView(style: .media) private let highlightedMaskView: UIView private let selectedMaskView: UIView @@ -117,14 +117,14 @@ class PhotoGridViewCell: UICollectionViewCell { contentView.addSubview(imageView) contentView.addSubview(highlightedMaskView) contentView.addSubview(selectedMaskView) - contentView.addSubview(selectionButton) + contentView.addSubview(selectionIndicator) imageView.autoPinEdgesToSuperviewEdges() highlightedMaskView.autoPinEdgesToSuperviewEdges() selectedMaskView.autoPinEdgesToSuperviewEdges() - selectionButton.autoPinEdge(toSuperviewEdge: .trailing, withInset: 5) - selectionButton.autoPinEdge(toSuperviewEdge: .top, withInset: 5) + selectionIndicator.autoPinEdge(toSuperviewEdge: .trailing, withInset: 5) + selectionIndicator.autoPinEdge(toSuperviewEdge: .top, withInset: 5) } @available(*, unavailable, message: "Unimplemented") @@ -134,8 +134,8 @@ class PhotoGridViewCell: UICollectionViewCell { private func updateSelectionState() { selectedMaskView.isHidden = !isSelected - selectionButton.isSelected = isSelected - selectionButton.allowsMultipleSelection = allowsMultipleSelection + selectionIndicator.isSelected = isSelected + selectionIndicator.isHidden = allowsMultipleSelection == false } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -279,7 +279,6 @@ class PhotoGridViewCell: UICollectionViewCell { durationLabelBackground?.isHidden = true highlightedMaskView.isHidden = true selectedMaskView.isHidden = true - selectionButton.reset() } func mediaPresentationContext(collectionView: UICollectionView, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { diff --git a/SignalUI/ContactSharing/ContactShareViewController.swift b/SignalUI/ContactSharing/ContactShareViewController.swift index ffdd635018..226414ca38 100644 --- a/SignalUI/ContactSharing/ContactShareViewController.swift +++ b/SignalUI/ContactSharing/ContactShareViewController.swift @@ -231,7 +231,7 @@ public class ContactShareViewController: OWSTableViewController2 { let field: ContactShareField - private lazy var checkmark = ListItemSelectionIndicatorView() + private lazy var checkmark = SelectionIndicatorView() init(field: ContactShareField) { self.field = field @@ -275,7 +275,7 @@ public class ContactShareViewController: OWSTableViewController2 { } class func contactNameCell(for contactName: String) -> UITableViewCell { - let checkmark = ListItemSelectionIndicatorView() + let checkmark = SelectionIndicatorView() checkmark.isSelected = true checkmark.isEnabled = false diff --git a/SignalUI/RecipientPickers/BaseMemberViewController.swift b/SignalUI/RecipientPickers/BaseMemberViewController.swift index 70af333420..658710d002 100644 --- a/SignalUI/RecipientPickers/BaseMemberViewController.swift +++ b/SignalUI/RecipientPickers/BaseMemberViewController.swift @@ -423,7 +423,7 @@ extension BaseMemberViewController: RecipientPickerDelegate { let accessoryView: UIView if isPreExistingMember { - let indicatorView = ListItemSelectionIndicatorView() + let indicatorView = SelectionIndicatorView() indicatorView.isSelected = true indicatorView.isEnabled = false @@ -432,13 +432,13 @@ extension BaseMemberViewController: RecipientPickerDelegate { if let customIndicatorView = memberViewDelegate.memberViewCustomIndicatorForPickedMember(recipient) { accessoryView = customIndicatorView } else { - let indicatorView = ListItemSelectionIndicatorView() + let indicatorView = SelectionIndicatorView() indicatorView.isSelected = true accessoryView = indicatorView } } else { - accessoryView = ListItemSelectionIndicatorView() + accessoryView = SelectionIndicatorView() } let accessoryViewWrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(accessoryView) return ContactCellAccessoryView(accessoryView: accessoryViewWrapper, size: .square(24)) diff --git a/SignalUI/RecipientPickers/ConversationPicker.swift b/SignalUI/RecipientPickers/ConversationPicker.swift index c1c59d612e..ba625ca3f5 100644 --- a/SignalUI/RecipientPickers/ConversationPicker.swift +++ b/SignalUI/RecipientPickers/ConversationPicker.swift @@ -1353,7 +1353,7 @@ class ConversationPickerCell: ContactTableViewCell { // MARK: - Subviews - private lazy var selectionView = ListItemSelectionIndicatorView() + private lazy var selectionView = SelectionIndicatorView() func buildAccessoryView(disappearingMessagesConfig: DisappearingMessagesConfigurationRecord?) -> ContactCellAccessoryView { diff --git a/SignalUI/Stories/MyStorySettingsViewController.swift b/SignalUI/Stories/MyStorySettingsViewController.swift index 691dd3e71a..f4249bc0f1 100644 --- a/SignalUI/Stories/MyStorySettingsViewController.swift +++ b/SignalUI/Stories/MyStorySettingsViewController.swift @@ -280,7 +280,7 @@ private class MyStorySettingsDataSource: NSObject { hStack.autoSetDimension(.height, toSize: 35, relation: .greaterThanOrEqual) hStack.autoPinHeightToSuperview(withMargin: 6) - let selectionIndicator = ListItemSelectionIndicatorView() + let selectionIndicator = SelectionIndicatorView() selectionIndicator.isSelected = isSelected hStack.addArrangedSubview(selectionIndicator) diff --git a/SignalUI/Views/CircleView.swift b/SignalUI/Views/CircleView.swift index 726553fb9c..1f10826930 100644 --- a/SignalUI/Views/CircleView.swift +++ b/SignalUI/Views/CircleView.swift @@ -150,6 +150,14 @@ public class RingView: UIView { } } + override public var bounds: CGRect { + didSet { + if bounds.size != oldValue.size { + updatePath() + } + } + } + override public var tintColor: UIColor! { didSet { updateColor() diff --git a/SignalUI/Views/ListItemSelectionIndicatorView.swift b/SignalUI/Views/ListItemSelectionIndicatorView.swift deleted file mode 100644 index 60e948dbd9..0000000000 --- a/SignalUI/Views/ListItemSelectionIndicatorView.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// Copyright 2026 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import SignalServiceKit -import UIKit - -public class ListItemSelectionIndicatorView: UIView { - - // MARK: UIView - - override init(frame: CGRect = .init(origin: .zero, size: .square(ListItemSelectionIndicatorView.preferredSize))) { - super.init(frame: frame) - - directionalLayoutMargins = .init(margin: 1) - - // 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) - - sizeToFit() - - addSubview(unselectedView) - addSubview(selectedView) - updateAppearance(animated: false) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Layout - - private static let preferredSize: CGFloat = 22 - - override public var intrinsicContentSize: CGSize { - .square(Self.preferredSize) + directionalLayoutMargins.asSize - } - - override public func layoutSubviews() { - super.layoutSubviews() - - let circleRadius = Self.preferredSize / 2 - let origin = CGPoint(x: bounds.center.x - circleRadius, y: bounds.center.y - circleRadius) - let size = CGSize.square(Self.preferredSize) - let frame = CGRect(origin: origin, size: size) - unselectedView.frame = frame - selectedView.frame = frame - 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) - } - - // MARK: Appearance - - private let unselectedView: UIView = { - let ringView = RingView() - ringView.lineWidth = 2 - ringView.tintColor = .Signal.tertiaryLabel - return ringView - }() - - private lazy var selectedView: UIView = { - let circleView = CircleView() - circleView.backgroundColor = .Signal.accent - circleView.addSubview(checkmarkIcon) - return circleView - }() - - private let checkmarkIcon: UIImageView = { - let imageView = UIImageView(image: UIImage(named: "check-compact")) - imageView.contentMode = .scaleAspectFit - imageView.tintColor = .white - return imageView - }() - - private func updateAppearance(animated: Bool) { - selectedView.setIsHidden(isSelected == false, animated: animated) - unselectedView.setIsHidden(isSelected, animated: animated) - - selectedView.backgroundColor = isEnabled ? .Signal.accent : .Signal.tertiaryLabel - } -} diff --git a/SignalUI/Views/SelectionIndicatorView.swift b/SignalUI/Views/SelectionIndicatorView.swift new file mode 100644 index 0000000000..80036da5a4 --- /dev/null +++ b/SignalUI/Views/SelectionIndicatorView.swift @@ -0,0 +1,177 @@ +// +// 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 + + private 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. + public var fillColor: UIColor = .Signal.accent { + didSet { + selectedView.backgroundColor = fillColor + } + } + + private var effectiveFillColor: UIColor { + isEnabled ? fillColor : .Signal.tertiaryLabel + } + + /// Color for the ckeckmark image and outer ring for media-style indicators. + public var strokeColor: UIColor = .white { + didSet { + checkmarkIcon.tintColor = strokeColor + if case .media = style { + outerRing.tintColor = strokeColor + } + } + } + + private lazy var innerRing: UIView = { + owsAssertDebug(style == .list, "Invalid access") + let ringView = RingView() + ringView.lineWidth = SelectionIndicatorView.ringStrokeWidth + ringView.tintColor = .Signal.tertiaryLabel + 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 + } +}