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

431 lines
14 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
public struct NewMember {
public let recipient: PickedRecipient
public let address: SignalServiceAddress
public let shortName: String
}
// MARK: -
public protocol NewMembersBarDelegate: NewMemberCellDelegate {
}
// MARK: -
public class NewMembersBar: UIView {
weak var delegate: NewMembersBarDelegate?
private var members = [NewMember]()
public func setMembers(_ members: [NewMember]) {
self.members = members
resetContentAndLayout()
updateHeightConstraint()
}
private let collectionView: UICollectionView
private let collectionViewLayout = NewMembersBarLayout()
private var heightConstraint: NSLayoutConstraint?
public init() {
self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
super.init(frame: .zero)
configure()
}
@available(*, unavailable, message: "use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configure() {
collectionViewLayout.layoutDelegate = self
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(NewMemberCell.self, forCellWithReuseIdentifier: NewMemberCell.reuseIdentifier)
collectionView.backgroundColor = .clear
collectionView.showsHorizontalScrollIndicator = false
addSubview(collectionView)
collectionView.autoPinEdgesToSuperviewEdges()
heightConstraint = autoSetDimension(.height, toSize: 0)
}
private func resetContentAndLayout() {
AssertIsOnMainThread()
collectionView.reloadData()
}
func updateHeightConstraint() {
guard let heightConstraint = heightConstraint else {
owsFailDebug("Missing heightConstraint.")
return
}
let desiredHeight = members.isEmpty ? 0 : self.contentHeight
if heightConstraint.constant != desiredHeight {
superview?.layoutIfNeeded()
collectionView.alpha = desiredHeight == 0 ? 1 : 0
heightConstraint.constant = desiredHeight
UIView.animate(withDuration: 0.25) { [weak self] in
self?.collectionView.alpha = desiredHeight == 0 ? 0 : 1
self?.superview?.layoutIfNeeded()
}
}
}
private var contentHeight: CGFloat {
collectionViewLayout.prepare()
return collectionViewLayout.collectionViewContentSize.height
}
public func scrollToRecipient(_ recipient: PickedRecipient) {
guard let index = members.firstIndex(where: { $0.recipient == recipient }) else {
owsFailDebug("Missing member.")
return
}
collectionView.scrollToItem(at: IndexPath(item: index, section: 0),
at: .centeredHorizontally,
animated: true)
}
}
// MARK: -
extension NewMembersBar: UICollectionViewDataSource {
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return members.count
}
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
return memberCell(at: indexPath)
}
fileprivate func memberCell(at indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NewMemberCell.reuseIdentifier, for: indexPath) as? NewMemberCell else {
owsFail("Missing or invalid cell.")
}
configure(cell: cell, indexPath: indexPath)
return cell
}
fileprivate func configure(cell: NewMemberCell, indexPath: IndexPath) {
guard let member = members[safe: indexPath.row] else {
owsFailDebug("Missing member.")
return
}
cell.configure(member: member)
assert(self.delegate != nil)
cell.delegate = self.delegate
#if DEBUG
// These accessibilityIdentifiers won't be stable, but they
// should work for the purposes of our automated testing.
cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "new-group-member-bar-\(indexPath.row)")
#endif
}
}
// MARK: -
extension NewMembersBar: UICollectionViewDelegate {
}
// MARK: -
public protocol NewMemberCellDelegate: AnyObject {
func removeRecipient(_ recipient: PickedRecipient)
}
// MARK: -
private class NewMemberCell: UICollectionViewCell {
static let reuseIdentifier = "NewMemberCell"
private let avatarView = ConversationAvatarView(sizeClass: avatarSizeClass, localUserDisplayMode: .asUser)
private let textLabel = UILabel(frame: .zero)
fileprivate weak var delegate: NewMemberCellDelegate?
fileprivate var member: NewMember?
static let avatarSizeClass = ConversationAvatarView.Configuration.SizeClass.customDiameter(32)
static let vMargin: CGFloat = 6
static let removeButtonXSize: CGFloat = 12
static let removeButtonInset: CGFloat = 5
// Don't use dynamic type in these cells.
static var nameFont = UIFont.regularFont(ofSize: 15)
override init(frame: CGRect) {
super.init(frame: frame)
self.layoutMargins = .zero
contentView.layoutMargins = .zero
contentView.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray15
textLabel.font = NewMemberCell.nameFont
textLabel.textColor = Theme.primaryTextColor
textLabel.numberOfLines = 1
textLabel.lineBreakMode = .byTruncatingTail
let removeButton = UIButton(type: .custom)
removeButton.setTemplateImage(Theme.iconImage(.buttonX), tintColor: Theme.primaryTextColor)
// Extend the hot area of the remove button.
removeButton.ows_imageEdgeInsets = UIEdgeInsets(
top: Self.removeButtonInset,
left: Self.removeButtonInset,
bottom: Self.removeButtonInset,
right: Self.removeButtonInset
)
removeButton.addTarget(self, action: #selector(removeButtonWasPressed), for: .touchUpInside)
let buttonSize = Self.removeButtonXSize + 2 * Self.removeButtonInset
removeButton.autoSetDimensions(to: CGSize(square: buttonSize))
removeButton.setContentHuggingHigh()
contentView.addSubview(avatarView)
avatarView.autoPinEdge(toSuperviewEdge: .leading)
avatarView.autoPinEdge(toSuperviewMargin: .top, relation: .greaterThanOrEqual)
avatarView.autoPinEdge(toSuperviewMargin: .bottom, relation: .greaterThanOrEqual)
let stackView = UIStackView(arrangedSubviews: [
textLabel,
removeButton
])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.layoutMargins = UIEdgeInsets(top: Self.vMargin, leading: 4, bottom: Self.vMargin, trailing: 2)
stackView.isLayoutMarginsRelativeArrangement = true
contentView.addSubview(stackView)
stackView.autoPinLeading(toTrailingEdgeOf: avatarView)
stackView.autoPinEdges(toSuperviewMarginsExcludingEdge: .leading)
stackView.setContentHuggingHorizontalLow()
stackView.setCompressionResistanceHorizontalLow()
}
override public func layoutSubviews() {
super.layoutSubviews()
contentView.layer.cornerRadius = contentView.height / 2
}
@available(*, unavailable, message: "use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func configure(member: NewMember) {
self.member = member
avatarView.updateWithSneakyTransactionIfNecessary { config in
config.dataSource = .address(member.address)
}
textLabel.text = member.shortName
}
override func prepareForReuse() {
super.prepareForReuse()
member = nil
avatarView.reset()
textLabel.text = nil
delegate = nil
}
@objc
private func removeButtonWasPressed() {
guard let recipient = member?.recipient else {
owsFailDebug("Missing recipient.")
return
}
delegate?.removeRecipient(recipient)
}
}
// MARK: -
extension NewMembersBar: NewMembersBarLayoutDelegate {
func cellForLayoutMeasurement(at indexPath: IndexPath) -> UICollectionViewCell {
let cell = NewMemberCell()
configure(cell: cell, indexPath: indexPath)
return cell
}
}
// MARK: -
private protocol NewMembersBarLayoutDelegate: AnyObject {
func cellForLayoutMeasurement(at indexPath: IndexPath) -> UICollectionViewCell
}
// MARK: -
// A simple horizontal layout.
private class NewMembersBarLayout: UICollectionViewLayout {
fileprivate weak var layoutDelegate: NewMembersBarLayoutDelegate?
private var itemAttributesMap = [UICollectionViewLayoutAttributes]()
private var contentSize: CGSize = .zero
// MARK: Initializers and Factory Methods
public override init() {
super.init()
}
@available(*, unavailable, message: "use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Methods
override func invalidateLayout() {
super.invalidateLayout()
itemAttributesMap.removeAll()
}
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
super.invalidateLayout(with: context)
itemAttributesMap.removeAll()
}
static let hMargin: CGFloat = 12
static let vMargin: CGFloat = 6
override func prepare() {
super.prepare()
guard let collectionView = collectionView else {
owsFailDebug("Missing collectionView.")
contentSize = .zero
return
}
guard let layoutDelegate = self.layoutDelegate else {
owsFailDebug("Missing layoutDelegate.")
contentSize = .zero
return
}
let hMargin = Self.hMargin
let vMargin = Self.vMargin
let hSpacing: CGFloat = 8
let collectionViewWidth = collectionView.width
guard collectionViewWidth > hMargin * 2 else {
contentSize = .zero
return
}
let maxRowWidth = collectionViewWidth - hMargin * 2
let maxCellWidth = maxRowWidth
let minCellWidth: CGFloat = 20
struct Item {
let indexPath: IndexPath
var frame: CGRect
}
// 1. Measure all cells and assign to rows with "x" values.
// NOTE: this pass ignores margins.
var items = [Item]()
var nextCellX: CGFloat = 0
let sectionCount = collectionView.numberOfSections
for section in 0..<sectionCount {
let itemCount = collectionView.numberOfItems(inSection: section)
for itemIndex in 0..<itemCount {
let indexPath = IndexPath(row: itemIndex, section: section)
let cell = layoutDelegate.cellForLayoutMeasurement(at: indexPath)
// We use layoutFittingExpandedSize to ensure we get a proper
// measurement for the input cell, whose contents are scrollable.
let cellSize = cell.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
let cellWidth = max(min(cellSize.width, maxCellWidth), minCellWidth)
var itemFrame = CGRect(x: 0, y: 0, width: cellWidth, height: cellSize.height)
itemFrame.origin.x = nextCellX
let item = Item(indexPath: indexPath, frame: itemFrame)
items.append(item)
nextCellX += itemFrame.width + hSpacing
}
}
guard !items.isEmpty else {
self.contentSize = .zero
return
}
// 2. Find max cell height.
let cellHeights: [CGFloat] = items.map { $0.frame.height }
let maxCellHeight = cellHeights.max()!
// 3. In a second pass, finalize positioning.
//
// * Assign "y" values.
// * Apply RTL.
// * Apply margins.
items = items.map { item in
var frame = item.frame
// V-center within row.
frame.origin.y = (maxCellHeight - frame.height) * 0.5
// Apply RTL
if CurrentAppContext().isRTL {
frame.origin.x = maxRowWidth - frame.maxX
}
// Apply margins.
frame.origin.x += hMargin
frame.origin.y += vMargin
return Item(indexPath: item.indexPath, frame: frame)
}
// 4. Update local state.
let cellFrames = items.map { $0.frame }
let maxX = cellFrames.map { $0.maxX }.max()!
let maxY = cellFrames.map { $0.maxY }.max()!
let contentWidth = maxX + hMargin
let contentHeight = maxY + vMargin
for item in items {
let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: item.indexPath)
itemAttributes.frame = item.frame
itemAttributesMap.append(itemAttributes)
}
contentSize = CGSize(width: contentWidth, height: contentHeight)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return itemAttributesMap.filter { itemAttributes in
return itemAttributes.frame.intersects(rect)
}
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return itemAttributesMap[safe: indexPath.row]
}
override var collectionViewContentSize: CGSize {
return contentSize
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let collectionView = collectionView else {
return false
}
return collectionView.width != newBounds.size.width
}
}