// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit extension SessionCell { public class AccessoryView: UIView, UISearchBarDelegate { // Note: We set a minimum width for the 'AccessoryView' so that the titles line up // nicely when we have a mix of icons and switches private static let minWidth: CGFloat = 50 private var onTap: ((SessionButton?) -> Void)? private var searchTermChanged: ((String?) -> Void)? // MARK: - UI private lazy var minWidthConstraint: NSLayoutConstraint = self.widthAnchor .constraint(greaterThanOrEqualToConstant: AccessoryView.minWidth) private lazy var fixedWidthConstraint: NSLayoutConstraint = self.set(.width, to: AccessoryView.minWidth) private lazy var imageViewConstraints: [NSLayoutConstraint] = [ imageView.pin(.top, to: .top, of: self), imageView.pin(.bottom, to: .bottom, of: self) ] private lazy var imageViewLeadingConstraint: NSLayoutConstraint = imageView.pin(.leading, to: .leading, of: self) private lazy var imageViewTrailingConstraint: NSLayoutConstraint = imageView.pin(.trailing, to: .trailing, of: self) private lazy var imageViewWidthConstraint: NSLayoutConstraint = imageView.set(.width, to: 0) private lazy var imageViewHeightConstraint: NSLayoutConstraint = imageView.set(.height, to: 0) private lazy var toggleSwitchConstraints: [NSLayoutConstraint] = [ toggleSwitch.pin(.top, to: .top, of: self), toggleSwitch.pin(.leading, to: .leading, of: self), toggleSwitch.pin(.trailing, to: .trailing, of: self), toggleSwitch.pin(.bottom, to: .bottom, of: self) ] private lazy var dropDownStackViewConstraints: [NSLayoutConstraint] = [ dropDownStackView.pin(.top, to: .top, of: self), dropDownStackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), dropDownStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), dropDownStackView.pin(.bottom, to: .bottom, of: self) ] private lazy var radioViewWidthConstraint: NSLayoutConstraint = radioView.set(.width, to: 0) private lazy var radioViewHeightConstraint: NSLayoutConstraint = radioView.set(.height, to: 0) private lazy var radioBorderViewWidthConstraint: NSLayoutConstraint = radioBorderView.set(.width, to: 0) private lazy var radioBorderViewHeightConstraint: NSLayoutConstraint = radioBorderView.set(.height, to: 0) private lazy var radioBorderViewConstraints: [NSLayoutConstraint] = [ radioBorderView.pin(.top, to: .top, of: self), radioBorderView.center(.horizontal, in: self), radioBorderView.pin(.bottom, to: .bottom, of: self) ] private lazy var highlightingBackgroundLabelConstraints: [NSLayoutConstraint] = [ highlightingBackgroundLabel.pin(.top, to: .top, of: self), highlightingBackgroundLabel.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self) ] private lazy var profilePictureViewConstraints: [NSLayoutConstraint] = [ profilePictureView.pin(.top, to: .top, of: self), profilePictureView.pin(.leading, to: .leading, of: self), profilePictureView.pin(.trailing, to: .trailing, of: self), profilePictureView.pin(.bottom, to: .bottom, of: self) ] private lazy var searchBarConstraints: [NSLayoutConstraint] = [ searchBar.pin(.top, to: .top, of: self), searchBar.pin(.leading, to: .leading, of: self, withInset: -8), // Removing default inset searchBar.pin(.trailing, to: .trailing, of: self, withInset: 8), // Removing default inset searchBar.pin(.bottom, to: .bottom, of: self) ] private lazy var buttonConstraints: [NSLayoutConstraint] = [ button.pin(.top, to: .top, of: self), button.pin(.leading, to: .leading, of: self), button.pin(.trailing, to: .trailing, of: self), button.pin(.bottom, to: .bottom, of: self) ] private let imageView: UIImageView = { let result: UIImageView = UIImageView() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.contentMode = .scaleAspectFit result.themeTintColor = .textPrimary result.layer.minificationFilter = .trilinear result.layer.magnificationFilter = .trilinear result.isHidden = true return result }() private let toggleSwitch: UISwitch = { let result: UISwitch = UISwitch() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false // Triggered by didSelectCell instead result.themeOnTintColor = .primary result.isHidden = true result.setContentHuggingHigh() result.setCompressionResistanceHigh() return result }() private let dropDownStackView: UIStackView = { let result: UIStackView = UIStackView() result.translatesAutoresizingMaskIntoConstraints = false result.axis = .horizontal result.distribution = .fill result.alignment = .center result.spacing = Values.verySmallSpacing result.isHidden = true return result }() private let dropDownImageView: UIImageView = { let result: UIImageView = UIImageView(image: UIImage(systemName: "arrowtriangle.down.fill")) result.translatesAutoresizingMaskIntoConstraints = false result.themeTintColor = .textPrimary result.set(.width, to: 10) result.set(.height, to: 10) return result }() private let dropDownLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false result.font = .systemFont(ofSize: Values.smallFontSize, weight: .medium) result.themeTextColor = .textPrimary result.setContentHuggingHigh() result.setCompressionResistanceHigh() return result }() private let radioBorderView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false result.layer.borderWidth = 1 result.themeBorderColor = .radioButton_unselectedBorder result.isHidden = true return result }() private let radioView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false result.themeBackgroundColor = .radioButton_unselectedBackground result.isHidden = true return result }() public lazy var highlightingBackgroundLabel: SessionHighlightingBackgroundLabel = { let result: SessionHighlightingBackgroundLabel = SessionHighlightingBackgroundLabel() result.translatesAutoresizingMaskIntoConstraints = false result.isHidden = true return result }() private lazy var profilePictureView: ProfilePictureView = { let result: ProfilePictureView = ProfilePictureView(size: .list) result.translatesAutoresizingMaskIntoConstraints = false result.isHidden = true return result }() private lazy var searchBar: UISearchBar = { let result: ContactsSearchBar = ContactsSearchBar() result.themeTintColor = .textPrimary result.themeBackgroundColor = .clear result.searchTextField.themeBackgroundColor = .backgroundSecondary result.delegate = self result.isHidden = true return result }() private lazy var button: SessionButton = { let result: SessionButton = SessionButton(style: .bordered, size: .medium) result.translatesAutoresizingMaskIntoConstraints = false result.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) result.isHidden = true return result }() private var customView: UIView? // MARK: - Initialization override init(frame: CGRect) { super.init(frame: frame) setupViewHierarchy() } required init?(coder: NSCoder) { super.init(coder: coder) setupViewHierarchy() } private func setupViewHierarchy() { addSubview(imageView) addSubview(toggleSwitch) addSubview(dropDownStackView) addSubview(radioBorderView) addSubview(highlightingBackgroundLabel) addSubview(profilePictureView) addSubview(button) addSubview(searchBar) dropDownStackView.addArrangedSubview(dropDownImageView) dropDownStackView.addArrangedSubview(dropDownLabel) radioBorderView.addSubview(radioView) radioView.center(in: radioBorderView) } // MARK: - Content func prepareForReuse() { isHidden = true onTap = nil searchTermChanged = nil imageView.image = nil imageView.themeTintColor = .textPrimary imageView.contentMode = .scaleAspectFit dropDownImageView.themeTintColor = .textPrimary dropDownLabel.text = "" dropDownLabel.themeTextColor = .textPrimary radioBorderView.themeBorderColor = .radioButton_unselectedBorder radioView.themeBackgroundColor = .radioButton_unselectedBackground highlightingBackgroundLabel.text = "" highlightingBackgroundLabel.themeTextColor = .textPrimary customView?.removeFromSuperview() imageView.isHidden = true toggleSwitch.isHidden = true dropDownStackView.isHidden = true radioBorderView.isHidden = true radioView.alpha = 1 radioView.isHidden = true highlightingBackgroundLabel.isHidden = true profilePictureView.isHidden = true button.isHidden = true searchBar.isHidden = true minWidthConstraint.constant = AccessoryView.minWidth minWidthConstraint.isActive = false fixedWidthConstraint.constant = AccessoryView.minWidth fixedWidthConstraint.isActive = false imageViewLeadingConstraint.isActive = false imageViewTrailingConstraint.isActive = false imageViewWidthConstraint.isActive = false imageViewHeightConstraint.isActive = false imageViewConstraints.forEach { $0.isActive = false } toggleSwitchConstraints.forEach { $0.isActive = false } dropDownStackViewConstraints.forEach { $0.isActive = false } radioViewWidthConstraint.isActive = false radioViewHeightConstraint.isActive = false radioBorderViewWidthConstraint.isActive = false radioBorderViewHeightConstraint.isActive = false radioBorderViewConstraints.forEach { $0.isActive = false } highlightingBackgroundLabelConstraints.forEach { $0.isActive = false } profilePictureViewConstraints.forEach { $0.isActive = false } searchBarConstraints.forEach { $0.isActive = false } buttonConstraints.forEach { $0.isActive = false } } public func update( with accessory: Accessory?, tintColor: ThemeValue, isEnabled: Bool ) { guard let accessory: Accessory = accessory else { return } // If we have an accessory value then this shouldn't be hidden self.isHidden = false switch accessory { case .icon(let image, let iconSize, let customTint, let shouldFill, let accessibility): imageView.accessibilityIdentifier = accessibility?.identifier imageView.accessibilityLabel = accessibility?.label imageView.image = image imageView.themeTintColor = (customTint ?? tintColor) imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit) imageView.isHidden = false switch iconSize { case .fit: imageView.sizeToFit() fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2))) fixedWidthConstraint.isActive = true imageViewWidthConstraint.constant = imageView.bounds.width imageViewHeightConstraint.constant = imageView.bounds.height default: fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant) imageViewWidthConstraint.constant = iconSize.size imageViewHeightConstraint.constant = iconSize.size } minWidthConstraint.isActive = !fixedWidthConstraint.isActive imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing) imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing) imageViewLeadingConstraint.isActive = true imageViewTrailingConstraint.isActive = true imageViewWidthConstraint.isActive = true imageViewHeightConstraint.isActive = true imageViewConstraints.forEach { $0.isActive = true } case .iconAsync(let iconSize, let customTint, let shouldFill, let accessibility, let setter): setter(imageView) imageView.accessibilityIdentifier = accessibility?.identifier imageView.accessibilityLabel = accessibility?.label imageView.themeTintColor = (customTint ?? tintColor) imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit) imageView.isHidden = false switch iconSize { case .fit: imageView.sizeToFit() fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2))) fixedWidthConstraint.isActive = true imageViewWidthConstraint.constant = imageView.bounds.width imageViewHeightConstraint.constant = imageView.bounds.height default: fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant) imageViewWidthConstraint.constant = iconSize.size imageViewHeightConstraint.constant = iconSize.size } minWidthConstraint.isActive = !fixedWidthConstraint.isActive imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing) imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing) imageViewLeadingConstraint.isActive = true imageViewTrailingConstraint.isActive = true imageViewWidthConstraint.isActive = true imageViewHeightConstraint.isActive = true imageViewConstraints.forEach { $0.isActive = true } case .toggle(let dataSource, let accessibility): toggleSwitch.accessibilityIdentifier = accessibility?.identifier toggleSwitch.accessibilityLabel = accessibility?.label toggleSwitch.isHidden = false toggleSwitch.isEnabled = isEnabled fixedWidthConstraint.isActive = true toggleSwitchConstraints.forEach { $0.isActive = true } let newValue: Bool = dataSource.currentBoolValue if newValue != toggleSwitch.isOn { toggleSwitch.setOn(newValue, animated: true) } case .dropDown(let dataSource, let accessibility): dropDownLabel.accessibilityIdentifier = accessibility?.identifier dropDownLabel.accessibilityLabel = accessibility?.label dropDownLabel.text = dataSource.currentStringValue dropDownStackView.isHidden = false dropDownStackViewConstraints.forEach { $0.isActive = true } minWidthConstraint.isActive = true case .radio(let size, let isSelectedRetriever, let storedSelection, let accessibility): let isSelected: Bool = isSelectedRetriever() let wasOldSelection: Bool = (!isSelected && storedSelection) radioBorderView.isHidden = false radioBorderView.themeBorderColor = { guard isEnabled else { return .radioButton_disabledBorder } return (isSelected ? .radioButton_selectedBorder : .radioButton_unselectedBorder ) }() radioBorderView.layer.cornerRadius = (size.borderSize / 2) radioView.accessibilityIdentifier = accessibility?.identifier radioView.accessibilityLabel = accessibility?.label radioView.alpha = (wasOldSelection ? 0.3 : 1) radioView.isHidden = (!isSelected && !storedSelection) radioView.themeBackgroundColor = { guard isEnabled else { return (isSelected || wasOldSelection ? .radioButton_disabledSelectedBackground : .radioButton_disabledUnselectedBackground ) } return (isSelected || wasOldSelection ? .radioButton_selectedBackground : .radioButton_unselectedBackground ) }() radioView.layer.cornerRadius = (size.selectionSize / 2) radioViewWidthConstraint.constant = size.selectionSize radioViewHeightConstraint.constant = size.selectionSize radioBorderViewWidthConstraint.constant = size.borderSize radioBorderViewHeightConstraint.constant = size.borderSize fixedWidthConstraint.isActive = true radioViewWidthConstraint.isActive = true radioViewHeightConstraint.isActive = true radioBorderViewWidthConstraint.isActive = true radioBorderViewHeightConstraint.isActive = true radioBorderViewConstraints.forEach { $0.isActive = true } case .highlightingBackgroundLabel(let title, let accessibility): highlightingBackgroundLabel.accessibilityIdentifier = accessibility?.identifier highlightingBackgroundLabel.accessibilityLabel = accessibility?.label highlightingBackgroundLabel.text = title highlightingBackgroundLabel.themeTextColor = tintColor highlightingBackgroundLabel.isHidden = false highlightingBackgroundLabelConstraints.forEach { $0.isActive = true } minWidthConstraint.isActive = true case .profile( let profileId, let profileSize, let threadVariant, let customImageData, let profile, let profileIcon, let additionalProfile, let additionalProfileIcon, let accessibility ): // Note: We MUST set the 'size' property before triggering the 'update' // function or the profile picture won't layout correctly profilePictureView.accessibilityIdentifier = accessibility?.identifier profilePictureView.accessibilityLabel = accessibility?.label profilePictureView.isAccessibilityElement = (accessibility != nil) profilePictureView.size = profileSize profilePictureView.update( publicKey: profileId, threadVariant: threadVariant, customImageData: customImageData, profile: profile, profileIcon: profileIcon, additionalProfile: additionalProfile, additionalProfileIcon: additionalProfileIcon ) profilePictureView.isHidden = false fixedWidthConstraint.constant = profileSize.viewSize fixedWidthConstraint.isActive = true profilePictureViewConstraints.forEach { $0.isActive = true } case .search(let placeholder, let accessibility, let searchTermChanged): self.searchTermChanged = searchTermChanged searchBar.accessibilityIdentifier = accessibility?.identifier searchBar.accessibilityLabel = accessibility?.label searchBar.placeholder = placeholder searchBar.isHidden = false searchBarConstraints.forEach { $0.isActive = true } case .button(let style, let title, let accessibility, let onTap): self.onTap = onTap button.accessibilityIdentifier = accessibility?.identifier button.accessibilityLabel = accessibility?.label button.setTitle(title, for: .normal) button.setStyle(style) button.isHidden = false minWidthConstraint.isActive = true buttonConstraints.forEach { $0.isActive = true } case .customView(_, let viewGenerator): let generatedView: UIView = viewGenerator() addSubview(generatedView) generatedView.pin(.top, to: .top, of: self) generatedView.pin(.leading, to: .leading, of: self) generatedView.pin(.trailing, to: .trailing, of: self) generatedView.pin(.bottom, to: .bottom, of: self) customView?.removeFromSuperview() // Just in case customView = generatedView minWidthConstraint.isActive = true } } // MARK: - Interaction func setHighlighted(_ highlighted: Bool, animated: Bool) { highlightingBackgroundLabel.setHighlighted(highlighted, animated: animated) } func setSelected(_ selected: Bool, animated: Bool) { highlightingBackgroundLabel.setSelected(selected, animated: animated) } @objc private func buttonTapped() { onTap?(button) } // MARK: - UISearchBarDelegate public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { searchTermChanged?(searchText) } public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { searchBar.setShowsCancelButton(true, animated: true) } public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { searchBar.setShowsCancelButton(false, animated: true) } public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.endEditing(true) } } }