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

703 lines
24 KiB
Swift

//
// 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(
icon: ThemeIcon? = nil,
withText: String,
maxNameLines: Int? = nil,
accessoryText: String? = nil,
actionBlock: (() -> Void)? = nil
) -> OWSTableItem {
return item(
icon: icon,
name: withText,
maxNameLines: maxNameLines,
accessoryText: accessoryText,
accessoryType: .disclosureIndicator,
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
})
}
public static func `switch`(
withText text: String,
accessibilityIdentifier: String? = nil,
isOn: @escaping (() -> Bool),
isEnabled: @escaping (() -> Bool) = { true },
actionBlock: @escaping ((UISwitch) -> Void) = { _ in }
) -> OWSTableItem {
return OWSTableItem(customCellBlock: {
let cell = OWSTableItem.buildCell(
itemName: text,
accessibilityIdentifier: accessibilityIdentifier
)
let cellSwitch = UISwitch()
cellSwitch.isOn = isOn()
cellSwitch.isEnabled = isEnabled()
cellSwitch.accessibilityIdentifier = accessibilityIdentifier
cellSwitch.addAction(UIAction(handler: { [unowned cellSwitch] _ in actionBlock(cellSwitch) }), 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 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 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(
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(
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(
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 = OWSTableItem.newCell()
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)
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()
})
}
}