// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit import UIKit public class OWSTableItem { public weak var tableViewController: UIViewController? public private(set) var title: String? private var customCellBlock: (() -> UITableViewCell)? private var dequeueCellBlock: ((UITableView) -> UITableViewCell)? public private(set) var willDisplayBlock: ((UITableViewCell) -> Void)? public var customRowHeight: CGFloat? public private(set) var actionBlock: (() -> Void)? public private(set) var contextMenuActionProvider: UIContextMenuActionProvider? // MARK: - public init(title: String?, actionBlock: (() -> Void)?) { self.title = title self.actionBlock = actionBlock } public convenience init() { self.init(title: nil, actionBlock: nil) } public convenience init( customCellBlock: @escaping () -> UITableViewCell, willDisplayBlock: ((UITableViewCell) -> Void)? = nil, actionBlock: (() -> Void)? = nil, contextMenuActionProvider: UIContextMenuActionProvider? = nil ) { self.init(title: nil, actionBlock: actionBlock) self.customCellBlock = customCellBlock self.willDisplayBlock = willDisplayBlock self.contextMenuActionProvider = contextMenuActionProvider } public convenience init( dequeueCellBlock: @escaping (UITableView) -> UITableViewCell, actionBlock: (() -> Void)? = nil, contextMenuActionProvider: UIContextMenuActionProvider? = nil ) { self.init(title: nil, actionBlock: actionBlock) self.dequeueCellBlock = dequeueCellBlock self.contextMenuActionProvider = contextMenuActionProvider } public convenience init( text: String, textColor: UIColor? = nil, actionBlock: (() -> Void)?, accessoryText: String? = nil, accessoryType: UITableViewCell.AccessoryType, accessibilityIdentifier: String? = nil ) { self.init( customCellBlock: { return OWSTableItem.buildCell( itemName: text, textColor: textColor, accessoryText: accessoryText, accessoryType: accessoryType, accessibilityIdentifier: accessibilityIdentifier ) }, actionBlock: actionBlock ) } // MARK: - Convenience constructors public static func checkmark( withText text: String, actionBlock: (() -> Void)? = nil, accessibilityIdentifier: String? = nil ) -> OWSTableItem { return OWSTableItem( customCellBlock: { return OWSTableItem.buildCell( itemName: text, accessoryType: .checkmark, accessibilityIdentifier: accessibilityIdentifier ) }, actionBlock: actionBlock ) } public static func disclosureItem( withText text: String, detailText: String? = nil, accessibilityIdentifier: String? = nil, actionBlock: (() -> Void)? = nil ) -> OWSTableItem { return OWSTableItem( customCellBlock: { return OWSTableItem.buildCell( itemName: text, accessoryText: detailText, accessoryType: .disclosureIndicator, accessibilityIdentifier: accessibilityIdentifier ) }, actionBlock: actionBlock ) } public static func actionItem( withText text: String, textColor: UIColor? = nil, accessibilityIdentifier: String? = nil, actionBlock: (() -> Void)? = nil ) -> OWSTableItem { return OWSTableItem( customCellBlock: { return OWSTableItem.buildCell( itemName: text, textColor: textColor, accessoryType: .disclosureIndicator, accessibilityIdentifier: accessibilityIdentifier ) }, actionBlock: actionBlock ) } public static func subpage( withText text: String, accessibilityIdentifier: String? = nil, actionBlock: @escaping ((UIViewController) -> Void) ) -> OWSTableItem { let item = OWSTableItem(customCellBlock: { return OWSTableItem.buildCell( itemName: text, accessoryType: .disclosureIndicator, accessibilityIdentifier: accessibilityIdentifier ) }) item.actionBlock = { [weak item] in guard let item else { return } guard let viewController = item.tableViewController else { owsFailDebug("viewController == nil") return } actionBlock(viewController) } return item } public static func softCenterLabel( withText text: String ) -> OWSTableItem { return OWSTableItem(customCellBlock: { let cell = OWSTableItem.newCell() let textLabel = UILabel() textLabel.text = text // These cells look quite different. // // Smaller font. textLabel.font = OWSTableItem.primaryLabelFont // Soft color. // TODO: Theme, review with design. textLabel.textColor = UIColor.ows_middleGray // Centered. textLabel.textAlignment = .center cell.contentView.addSubview(textLabel) textLabel.autoPinEdgesToSuperviewMargins() cell.isUserInteractionEnabled = false return cell }) } public static func label( withText text: String, accessoryText: String? = nil, accessoryType: UITableViewCell.AccessoryType = .disclosureIndicator ) -> OWSTableItem { return OWSTableItem(customCellBlock: { let cell = OWSTableItem.buildCell( itemName: text, accessoryText: accessoryText, accessoryType: accessoryType ) cell.isUserInteractionEnabled = false return cell }) } public static func longDisclosure( withText text: String, actionBlock: (() -> Void)? = nil ) -> OWSTableItem { return OWSTableItem( customCellBlock: { let cell = OWSTableItem.newCell() cell.accessoryType = .disclosureIndicator let textLabel = UILabel() textLabel.text = text textLabel.numberOfLines = 0 textLabel.lineBreakMode = .byWordWrapping cell.contentView.addSubview(textLabel) textLabel.autoPinEdgesToSuperviewMargins() return cell }, actionBlock: actionBlock ) } public static func `switch`( withText text: String, accessibilityIdentifier: String? = nil, isOn: @escaping (() -> Bool), isEnabled: @escaping (() -> Bool) = { true }, target: AnyObject, selector: Selector ) -> OWSTableItem { return OWSTableItem(customCellBlock: { [weak target] in let cell = OWSTableItem.buildCell( itemName: text, accessibilityIdentifier: accessibilityIdentifier ) let cellSwitch = UISwitch() cellSwitch.isOn = isOn() cellSwitch.isEnabled = isEnabled() cellSwitch.accessibilityIdentifier = accessibilityIdentifier cellSwitch.addTarget(target, action: selector, for: .valueChanged) cell.accessoryView = cellSwitch cell.selectionStyle = .none return cell }) } // MARK: - Table View Cells public class func newCell() -> UITableViewCell { let cell = UITableViewCell() configureCell(cell) return cell } public class func configureCell(_ cell: UITableViewCell) { cell.selectedBackgroundView = UIView() cell.backgroundColor = Theme.backgroundColor cell.selectedBackgroundView?.backgroundColor = Theme.tableCell2SelectedBackgroundColor cell.multipleSelectionBackgroundView?.backgroundColor = Theme.tableCell2MultiSelectedBackgroundColor configureCellLabels(cell) } private class func configureCellLabels(_ cell: UITableViewCell) { cell.textLabel?.font = self.primaryLabelFont cell.textLabel?.textColor = Theme.primaryTextColor cell.detailTextLabel?.textColor = Theme.secondaryTextAndIconColor cell.detailTextLabel?.font = OWSTableItem.accessoryLabelFont } func getOrBuildCustomCell(_ tableView: UITableView) -> UITableViewCell? { if let customCellBlock { owsAssertDebug(dequeueCellBlock == nil) return customCellBlock() } if let dequeueCellBlock { return dequeueCellBlock(tableView) } return nil } } public extension OWSTableItem { static var primaryLabelFont: UIFont { .dynamicTypeBodyClamped } static var accessoryLabelFont: UIFont { .dynamicTypeBodyClamped } static var iconSpacing: CGFloat { 16 } static var iconSize: CGFloat { 24 } private static func accessoryGray() -> UIColor { Theme.isDarkThemeEnabled ? .ows_gray25 : .ows_gray45 } static func buildCell(name: String, iconView: UIView) -> UITableViewCell { return buildCell(name: name, iconView: iconView, iconSpacing: self.iconSpacing) } static func buildCell(name: String, iconView: UIView, iconSpacing: CGFloat) -> UITableViewCell { assert(!name.isEmpty) let cell = OWSTableItem.newCell() cell.preservesSuperviewLayoutMargins = true cell.contentView.preservesSuperviewLayoutMargins = true let rowLabel = UILabel() rowLabel.text = name rowLabel.textColor = Theme.primaryTextColor rowLabel.font = OWSTableItem.primaryLabelFont rowLabel.lineBreakMode = .byTruncatingTail let contentRow = UIStackView(arrangedSubviews: [ iconView, rowLabel ]) contentRow.spacing = iconSpacing cell.contentView.addSubview(contentRow) contentRow.autoPinEdgesToSuperviewMargins() return cell } static func imageView( forIcon icon: ThemeIcon, tintColor: UIColor? = nil, iconSize: CGFloat = iconSize ) -> UIImageView { let iconImage = Theme.iconImage(icon) let iconView = imageView(forImage: iconImage) iconView.tintColor = tintColor ?? Theme.primaryIconColor iconView.autoSetDimensions(to: .square(iconSize)) return iconView } private static func imageView( forImage: UIImage, iconSize: CGFloat = iconSize ) -> UIImageView { let result = UIImageView(image: forImage) result.contentMode = .scaleAspectFit result.layer.minificationFilter = .trilinear result.layer.magnificationFilter = .trilinear result.autoSetDimensions(to: CGSize(square: iconSize)) return result } static func buildCell( name: String, icon: ThemeIcon, accessibilityIdentifier: String? = nil ) -> UITableViewCell { let iconView = imageView(forIcon: icon) let cell = buildCell(name: name, iconView: iconView) cell.accessibilityIdentifier = accessibilityIdentifier return cell } static func buildDisclosureCell( name: String, icon: ThemeIcon, accessibilityIdentifier: String ) -> UITableViewCell { let cell = buildCell(name: name, icon: icon) cell.accessoryType = .disclosureIndicator cell.accessibilityIdentifier = accessibilityIdentifier return cell } static func buildLabelCell( name: String, icon: ThemeIcon, accessibilityIdentifier: String ) -> UITableViewCell { let cell = buildCell(name: name, icon: icon) cell.accessoryType = .none cell.accessibilityIdentifier = accessibilityIdentifier return cell } static func disclosureItem( icon: ThemeIcon, name: String, maxNameLines: Int? = nil, accessoryText: String? = nil, accessibilityIdentifier: String, actionBlock: (() -> Void)? ) -> OWSTableItem { return item( icon: icon, name: name, maxNameLines: maxNameLines, accessoryText: accessoryText, accessoryType: .disclosureIndicator, accessibilityIdentifier: accessibilityIdentifier, actionBlock: actionBlock ) } static func item( icon: ThemeIcon? = nil, tintColor: UIColor? = nil, name: String, subtitle: String? = nil, maxNameLines: Int? = nil, textColor: UIColor? = nil, accessoryText: String? = nil, accessoryType: UITableViewCell.AccessoryType = .none, accessoryContentView: UIView? = nil, accessibilityIdentifier: String? = nil, actionBlock: (() -> Void)? = nil ) -> OWSTableItem { return OWSTableItem( customCellBlock: { OWSTableItem.buildCell( icon: icon, tintColor: tintColor, itemName: name, subtitle: subtitle, maxItemNameLines: maxNameLines, textColor: textColor, accessoryText: accessoryText, accessoryType: accessoryType, accessoryContentView: accessoryContentView, accessibilityIdentifier: accessibilityIdentifier ) }, actionBlock: actionBlock ) } static func buildCell( baseCell: UITableViewCell? = nil, contentWrapperView: UIView? = nil, icon: ThemeIcon? = nil, tintColor: UIColor? = nil, itemName: String, subtitle: String? = nil, maxItemNameLines: Int? = nil, textColor: UIColor? = nil, accessoryText: String? = nil, accessoryTextColor: UIColor? = nil, accessoryType: UITableViewCell.AccessoryType = .none, accessoryContentView: UIView? = nil, customColor: UIColor? = nil, accessibilityIdentifier: String? = nil ) -> UITableViewCell { var imageView: UIImageView? if let icon = icon { let iconView = self.imageView(forIcon: icon, tintColor: customColor ?? tintColor, iconSize: iconSize) iconView.setCompressionResistanceHorizontalHigh() imageView = iconView } return buildImageViewCell( baseCell: baseCell, contentWrapperView: contentWrapperView, imageView: imageView, itemName: itemName, subtitle: subtitle, maxItemNameLines: maxItemNameLines, textColor: textColor, accessoryText: accessoryText, accessoryTextColor: accessoryTextColor, accessoryType: accessoryType, accessoryContentView: accessoryContentView, customColor: customColor, accessibilityIdentifier: accessibilityIdentifier ) } static func buildImageCell( image: UIImage? = nil, itemName: String, subtitle: String? = nil, maxItemNameLines: Int? = nil, accessoryText: String? = nil, accessoryTextColor: UIColor? = nil, accessoryType: UITableViewCell.AccessoryType = .none, accessoryContentView: UIView? = nil, accessibilityIdentifier: String? = nil ) -> UITableViewCell { var imageView: UIImageView? if let image = image { let imgView = self.imageView(forImage: image) imgView.setCompressionResistanceHorizontalHigh() imageView = imgView } return buildImageViewCell( imageView: imageView, itemName: itemName, subtitle: subtitle, maxItemNameLines: maxItemNameLines, accessoryText: accessoryText, accessoryTextColor: accessoryTextColor, accessoryType: accessoryType, accessoryContentView: accessoryContentView, accessibilityIdentifier: accessibilityIdentifier ) } private static func buildImageViewCell( baseCell: UITableViewCell? = nil, contentWrapperView: UIView? = nil, imageView: UIImageView? = nil, tintColor: UIColor? = nil, itemName: String, subtitle: String? = nil, maxItemNameLines: Int? = nil, textColor: UIColor? = nil, accessoryText: String? = nil, accessoryTextColor: UIColor? = nil, accessoryType: UITableViewCell.AccessoryType = .none, accessoryContentView: UIView? = nil, customColor: UIColor? = nil, accessibilityIdentifier: String? = nil ) -> UITableViewCell { let cell: UITableViewCell = { guard let baseCell else { return OWSTableItem.newCell() } configureCell(baseCell) return baseCell }() cell.preservesSuperviewLayoutMargins = true cell.contentView.preservesSuperviewLayoutMargins = true var subviews = [UIView]() if let imageView = imageView { subviews.append(imageView) } let nameLabel = UILabel() nameLabel.text = itemName if let textColor = textColor { nameLabel.textColor = textColor } else { nameLabel.textColor = Theme.primaryTextColor } nameLabel.font = OWSTableItem.primaryLabelFont nameLabel.adjustsFontForContentSizeCategory = true // Having two side-by-side multi-line labels makes // autolayout *really* confused because it doesn't // seem to know which height to respect (if they are // of different intrinsic height). It leads to lots of // very strange indeterminant behavior. To work around, // we only allow the longer of the two labels to be // multi-line. if let maxItemNameLines = maxItemNameLines { nameLabel.numberOfLines = maxItemNameLines nameLabel.lineBreakMode = .byTruncatingTail nameLabel.setCompressionResistanceHorizontalHigh() } else if itemName.count >= (accessoryText ?? "").count { nameLabel.numberOfLines = 0 nameLabel.lineBreakMode = .byWordWrapping nameLabel.setCompressionResistanceHorizontalLow() } else { nameLabel.numberOfLines = 1 nameLabel.lineBreakMode = .byTruncatingTail nameLabel.setCompressionResistanceHorizontalHigh() } nameLabel.setContentHuggingLow() nameLabel.setCompressionResistanceVerticalHigh() if subtitle == nil { subviews.append(nameLabel) } else { let subtitleLabel = UILabel() subtitleLabel.text = subtitle subtitleLabel.textColor = accessoryGray() subtitleLabel.font = .dynamicTypeFootnoteClamped let labels = UIStackView(arrangedSubviews: [nameLabel, subtitleLabel]) labels.axis = .vertical subviews.append(labels) } if let customColor = customColor { nameLabel.textColor = customColor } if let accessoryContentView { owsAssertDebug(accessoryText == nil) subviews.append(accessoryContentView) } else if let accessoryText { let accessoryLabel = UILabel() accessoryLabel.text = accessoryText accessoryLabel.textColor = accessoryTextColor ?? accessoryGray() accessoryLabel.font = OWSTableItem.accessoryLabelFont accessoryLabel.adjustsFontForContentSizeCategory = true if itemName.count >= accessoryText.count { accessoryLabel.numberOfLines = 1 accessoryLabel.lineBreakMode = .byTruncatingTail accessoryLabel.setCompressionResistanceHorizontalHigh() } else { accessoryLabel.numberOfLines = 0 accessoryLabel.lineBreakMode = .byWordWrapping accessoryLabel.setCompressionResistanceHorizontalLow() } accessoryLabel.setCompressionResistanceVerticalHigh() accessoryLabel.setContentHuggingHorizontalHigh() accessoryLabel.setContentHuggingVerticalLow() subviews.append(accessoryLabel) } let contentRow = UIStackView(arrangedSubviews: subviews) contentRow.axis = .horizontal contentRow.alignment = .center contentRow.spacing = self.iconSpacing contentRow.setContentHuggingHigh() contentRow.autoSetDimension(.height, toSize: iconSize, relation: .greaterThanOrEqual) if let contentWrapperView { cell.contentView.addSubview(contentWrapperView) contentWrapperView.addSubview(contentRow) contentWrapperView.autoPinEdgesToSuperviewMargins() contentRow.autoPinEdgesToSuperviewEdges() } else { cell.contentView.addSubview(contentRow) contentRow.autoPinEdgesToSuperviewMargins() } cell.accessibilityIdentifier = accessibilityIdentifier cell.accessoryType = accessoryType return cell } } // MARK: - public extension OWSTableItem { static func buildIconInCircleView( icon: ThemeIcon, iconSize iconSizeParam: UInt, innerIconSize: CGFloat, iconTintColor: UIColor ) -> UIView { let iconSize = CGFloat(iconSizeParam) let iconView = OWSTableItem.imageView(forIcon: icon, tintColor: iconTintColor, iconSize: innerIconSize) let iconWrapper = UIView.container() iconWrapper.addSubview(iconView) iconView.autoCenterInSuperview() iconWrapper.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray02 iconWrapper.layer.cornerRadius = iconSize * 0.5 iconWrapper.autoSetDimensions(to: CGSize(square: iconSize)) iconWrapper.setCompressionResistanceHigh() iconWrapper.setContentHuggingHigh() return iconWrapper } } // MARK: - Copyable item public extension OWSTableItem { /// An item that displays a labeled value that is copied on selection. A /// toast is presented on selection. /// /// - Parameter value /// The value to display, if any. /// - Parameter pasteboardValue /// The value to copy, if different than ``value``. static func copyableItem( label: String, value displayValue: String?, pasteboardValue: String? = nil, accessibilityIdentifier: String? = nil ) -> OWSTableItem { let pasteboardValue = pasteboardValue ?? displayValue let accessibilityIdentifier = accessibilityIdentifier ?? label return .item( name: label, accessoryText: displayValue, accessibilityIdentifier: accessibilityIdentifier, actionBlock: { if let pasteboardValue = pasteboardValue { UIPasteboard.general.string = pasteboardValue } guard let fromVC = CurrentAppContext().frontmostViewController() else { owsFailDebug("could not identify frontmostViewController") return } let toast = OWSLocalizedString( "COPIED_TO_CLIPBOARD", comment: "Indicator that a value has been copied to the clipboard." ) fromVC.presentToast(text: toast) } ) } } // MARK: - Item wrapping custom view public extension OWSTableItem { private class ViewWrappingTableViewCell: UITableViewCell { init(viewToWrap: UIView, margins: UIEdgeInsets) { super.init(style: .default, reuseIdentifier: nil) selectionStyle = .none contentView.addSubview(viewToWrap) contentView.layoutMargins = margins viewToWrap.autoPinEdgesToSuperviewMargins() } required init?(coder: NSCoder) { owsFail("Not implemented!") } } static func itemWrappingView( viewBlock: @escaping () -> UIView?, margins: UIEdgeInsets ) -> OWSTableItem { return OWSTableItem(customCellBlock: { guard let view = viewBlock() else { return UITableViewCell() } return ViewWrappingTableViewCell( viewToWrap: view, margins: margins ) }) } } // MARK: - Text field item public extension OWSTableItem { // [Colors] TODO: When proper dynamic colors are added, the `textColor` // property here should be moved to `OWSTextField` since it won't need to be // explicitly updated when the table refreshes. /// Creates an ``OWSTableItem`` with the text field while optionally setting /// its font and/or text color. /// - Parameters: /// - textField: The text field to use in the table item's cell. /// - textColor: The text color to set on the text field. /// If this value is `nil`, the text color will not be modified. /// Default value is `nil`. /// - Returns: An ``OWSTableItem`` with the `textField` embedded. static func textFieldItem( _ textField: UITextField, textColor: @escaping @autoclosure () -> UIColor? = Theme.primaryTextColor ) -> OWSTableItem { .init(customCellBlock: { if let textColor = textColor() { textField.textColor = textColor } let cell = Self.newCell() cell.selectionStyle = .none cell.addSubview(textField) textField.autoPinEdgesToSuperviewMargins() return cell }, actionBlock: { textField.becomeFirstResponder() }) } }