diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index fd5a15426..3afa0ab5b 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -276,6 +276,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate identifier: "Contact" ) ), + tableSize: tableView.bounds.size, using: dependencies ) diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index 1515e9e4e..7bb394059 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -468,6 +468,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), isEnabled: (authorId == self.messageViewModel.currentUserSessionId) ), + tableSize: tableView.bounds.size, using: dependencies ) diff --git a/Session/Path/PathStatusView.swift b/Session/Path/PathStatusView.swift index b525f4dcc..228c156ac 100644 --- a/Session/Path/PathStatusView.swift +++ b/Session/Path/PathStatusView.swift @@ -120,7 +120,7 @@ final class PathStatusViewAccessory: UIView, SessionCell.Accessory.CustomView { height: IconSize.medium.size ) - static func create(using dependencies: Dependencies) -> PathStatusViewAccessory { + static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> PathStatusViewAccessory { return PathStatusViewAccessory(using: dependencies) } diff --git a/Session/Settings/Views/AppIconGridView.swift b/Session/Settings/Views/AppIconGridView.swift index fd04e74c7..b135e5b13 100644 --- a/Session/Settings/Views/AppIconGridView.swift +++ b/Session/Settings/Views/AppIconGridView.swift @@ -11,13 +11,15 @@ final class AppIconGridView: UIView { /// Excluding the default icon private var icons: [AppIcon] = AppIcon.allCases.filter { $0 != .session } private var onChange: ((AppIcon) -> ())? + private let maxContentWidth: CGFloat // MARK: - Components lazy var contentViewViewHeightConstraint: NSLayoutConstraint = contentView.heightAnchor - .constraint(equalToConstant: IconView.expectedSize) + .constraint(equalToConstant: IconView.expectedMinSize) private var iconViewTopConstraints: [NSLayoutConstraint] = [] private var iconViewLeadingConstraints: [NSLayoutConstraint] = [] + private var iconViewWidthConstraints: [NSLayoutConstraint] = [] private let contentView: UIView = UIView() @@ -27,7 +29,9 @@ final class AppIconGridView: UIView { // MARK: - Initializtion - init() { + init(maxContentWidth: CGFloat) { + self.maxContentWidth = maxContentWidth + super.init(frame: .zero) setupUI() @@ -53,29 +57,68 @@ final class AppIconGridView: UIView { iconViews.forEach { iconViewTopConstraints.append($0.pin(.top, to: .top, of: contentView)) iconViewLeadingConstraints.append($0.pin(.leading, to: .leading, of: contentView)) + iconViewWidthConstraints.append($0.set(.width, to: IconView.minImageSize)) + } + } + + /// We want the icons to fill the available space in either a 6x1 grid or a 3x2 grid depending on the available width so + /// we need to calculate the `targetSize` and `targetSpacing` for the `IconView` + private func calculatedSizes(for availableWidth: CGFloat) -> (size: CGFloat, spacing: CGFloat) { + let acceptedIconsPerColumn: [CGFloat] = [CGFloat(iconViews.count), 3] + let minSpacing: CGFloat = Values.smallSpacing + + for iconsPerColumn in acceptedIconsPerColumn { + let minTotalSpacing: CGFloat = ((iconsPerColumn - 1) * minSpacing) + let availableWidthLessSpacing: CGFloat = (availableWidth - minTotalSpacing) + let size: CGFloat = floor(availableWidthLessSpacing / iconsPerColumn) + let spacing: CGFloat = ((availableWidth - (size * iconsPerColumn)) / (iconsPerColumn - 1)) + + /// If all of the icons would fit and be larger than the expected min size then that's the size we want to use + if size >= IconView.expectedMinSize { + return (size, spacing) + } } -// iconViews.last?.pin(.bottom, to: .bottom, of: contentView) + /// Fallback to the min sizes to prevent a future change resulting in a `0` value + return (IconView.expectedMinSize, minSpacing) } - override var intrinsicContentSize: CGSize { - var x: CGFloat = 0 - let availableWidth = (bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width) - let expectedHeight: CGFloat = iconViews.enumerated().reduce(into: 0) { result, next in + private func calculateIconViewFrames() -> [CGRect] { + let (targetSize, targetSpacing): (CGFloat, CGFloat) = calculatedSizes(for: maxContentWidth) + var nextX: CGFloat = 0 + var nextY: CGFloat = 0 + + /// We calculate the size based on the position for the next `IconView` so we will end up with an extra `Values.smallSpacing` + /// on both dimensions which needs to be removed + return iconViews.enumerated().reduce(into: []) { result, next in + /// First add the calculated position/size for this element + result.append( + CGRect( + x: nextX, + y: nextY, + width: targetSize, + height: targetSize + ) + ) + + /// We are at the last element so no need to calculate additional frames guard next.offset < iconViews.count - 1 else { return } - x = (x + IconView.expectedSize + Values.smallSpacing) + /// Calculate the position the next `IconView` should have + nextX += (targetSize + targetSpacing) - if x + IconView.expectedSize > availableWidth { - x = 0 - result = (result + IconView.expectedSize + Values.smallSpacing) + /// If the end of the next icon would go past the `maxContentWidth` then wrap to the next line + if nextX + targetSize > maxContentWidth { + nextX = 0 + nextY += (targetSize + targetSpacing) } } - - return CGSize( - width: UIView.noIntrinsicMetric, - height: (expectedHeight + IconView.expectedSize) - ) + } + + override var intrinsicContentSize: CGSize { + return calculateIconViewFrames().reduce(.zero) { result, next -> CGSize in + CGSize(width: max(result.width, next.maxX), height: max(result.height, next.maxY)) + } } override func layoutSubviews() { @@ -87,31 +130,28 @@ final class AppIconGridView: UIView { !iconViewLeadingConstraints.contains(where: { $0.constant > 0 }) else { return } - /// We manually layout the `IconView` instances because it's easier than trying to get - /// a good "overflow" behaviour doing it manually than using existing UI elements - var targetX: CGFloat = 0 - var targetY: CGFloat = 0 + /// We manually layout the `IconView` instances because it's easier than trying to get a good "overflow" behaviour doing it + /// manually than using existing UI elements + let frames: [CGRect] = calculateIconViewFrames() + + /// Sanity check to avoid an index out of bounds + guard + iconViews.count == frames.count && + iconViews.count == iconViewTopConstraints.count && + iconViews.count == iconViewLeadingConstraints.count && + iconViews.count == iconViewWidthConstraints.count + else { return } iconViews.enumerated().forEach { index, iconView in - iconViewTopConstraints[index].constant = targetY - iconViewLeadingConstraints[index].constant = targetX + iconViewTopConstraints[index].constant = frames[index].minY + iconViewLeadingConstraints[index].constant = frames[index].minX + iconViewWidthConstraints[index].constant = frames[index].width UIView.performWithoutAnimation { iconView.layoutIfNeeded() } - - /// Only update the target positions if there are more views - guard index < iconViews.count - 1 else { return } - - /// Calculate the X position for the next icon - targetX = (targetX + IconView.expectedSize + Values.smallSpacing) - - /// If there is no more room then overflow to the next line - if targetX + IconView.expectedSize > bounds.width { - targetX = 0 - targetY = (targetY + IconView.expectedSize + Values.smallSpacing) - } } - contentViewViewHeightConstraint.constant = (targetY + IconView.expectedSize) + contentViewViewHeightConstraint.constant = frames + .reduce(0) { result, next -> CGFloat in max(result, next.maxY) } } // MARK: - Content @@ -143,8 +183,8 @@ extension AppIconGridView: SessionCell.Accessory.CustomView { } } - static func create(using dependencies: Dependencies) -> AppIconGridView { - return AppIconGridView() + static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> AppIconGridView { + return AppIconGridView(maxContentWidth: maxContentWidth) } func update(with info: Info) { @@ -156,9 +196,9 @@ extension AppIconGridView: SessionCell.Accessory.CustomView { extension AppIconGridView { class IconView: UIView { - fileprivate static let imageSize: CGFloat = 85 + fileprivate static let minImageSize: CGFloat = 85 fileprivate static let selectionInset: CGFloat = 4 - fileprivate static var expectedSize: CGFloat = (imageSize + (selectionInset * 2)) + fileprivate static var expectedMinSize: CGFloat = (minImageSize + (selectionInset * 2)) private let onSelected: () -> () @@ -189,6 +229,7 @@ extension AppIconGridView { result.isUserInteractionEnabled = false result.contentMode = .scaleAspectFit result.layer.cornerRadius = 16 + result.clipsToBounds = true return result }() @@ -227,8 +268,7 @@ extension AppIconGridView { selectionBorderView.pin(to: self) imageView.pin(to: selectionBorderView, withInset: IconView.selectionInset) - imageView.set(.width, to: IconView.imageSize) - imageView.set(.height, to: IconView.imageSize) + imageView.set(.height, to: .width, of: imageView) } // MARK: - Content diff --git a/Session/Settings/Views/PrimaryColorSelectionView.swift b/Session/Settings/Views/PrimaryColorSelectionView.swift index 289bcba20..7e054955c 100644 --- a/Session/Settings/Views/PrimaryColorSelectionView.swift +++ b/Session/Settings/Views/PrimaryColorSelectionView.swift @@ -113,7 +113,7 @@ extension PrimaryColorSelectionView: SessionCell.Accessory.CustomView { } } - static func create(using dependencies: Dependencies) -> PrimaryColorSelectionView { + static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> PrimaryColorSelectionView { return PrimaryColorSelectionView() } diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index d1bd4a3ce..72fc3077b 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -106,7 +106,7 @@ extension ThemeMessagePreviewView: SessionCell.Accessory.CustomView { typealias View = ThemeMessagePreviewView } - static func create(using dependencies: Dependencies) -> ThemeMessagePreviewView { + static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemeMessagePreviewView { return ThemeMessagePreviewView(using: dependencies) } diff --git a/Session/Settings/Views/ThemePreviewView.swift b/Session/Settings/Views/ThemePreviewView.swift index dfea4ec0a..332279770 100644 --- a/Session/Settings/Views/ThemePreviewView.swift +++ b/Session/Settings/Views/ThemePreviewView.swift @@ -87,7 +87,7 @@ extension ThemePreviewView: SessionCell.Accessory.CustomView { let theme: Theme } - static func create(using dependencies: Dependencies) -> ThemePreviewView { + static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemePreviewView { return ThemePreviewView() } diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 4a93f88e6..8a475cc3c 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -454,7 +454,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa switch (cell, info) { case (let cell as SessionCell, _): - cell.update(with: info, using: viewModel.dependencies) + cell.update(with: info, tableSize: tableView.bounds.size, using: viewModel.dependencies) cell.update( isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)), becomeFirstResponder: false, @@ -675,7 +675,12 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa ) { // Try update the existing cell to have a nice animation instead of reloading the cell if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell { - existingCell.update(with: info, isManualReload: true, using: viewModel.dependencies) + existingCell.update( + with: info, + tableSize: tableView.bounds.size, + isManualReload: true, + using: viewModel.dependencies + ) } else { tableView.reloadRows(at: [indexPath], with: .none) diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index e23bd46d3..2cc1c1b6f 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -697,8 +697,8 @@ public extension SessionCell.AccessoryConfig { // MARK: - Conformance - public func createView(using dependencies: Dependencies) -> UIView { - return info.createView(using: dependencies) + public func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView { + return info.createView(maxContentWidth: maxContentWidth, using: dependencies) } override public func hash(into hasher: inout Hasher) { @@ -718,7 +718,7 @@ public extension SessionCell.AccessoryConfig { protocol AnyCustom { var accessibility: Accessibility? { get } - func createView(using dependencies: Dependencies) -> UIView + func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView } } @@ -738,7 +738,7 @@ public extension SessionCell.Accessory { static var size: Size { get } - static func create(using dependencies: Dependencies) -> Self + static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> Self func update(with info: Info) } @@ -748,8 +748,8 @@ public extension SessionCell.Accessory { } public extension SessionCell.Accessory.CustomViewInfo { - func createView(using dependencies: Dependencies) -> UIView { - let view: View = View.create(using: dependencies) + func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView { + let view: View = View.create(maxContentWidth: maxContentWidth, using: dependencies) view.update(with: self) switch View.size { diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 65230e5df..41e863e90 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -289,6 +289,7 @@ extension SessionCell { with accessory: Accessory?, tintColor: ThemeValue, isEnabled: Bool, + maxContentWidth: CGFloat, isManualReload: Bool, using dependencies: Dependencies ) { @@ -594,7 +595,10 @@ extension SessionCell { // MARK: -- Custom case let accessory as SessionCell.AccessoryConfig.AnyCustom: - let generatedView: UIView = accessory.createView(using: dependencies) + let generatedView: UIView = accessory.createView( + maxContentWidth: maxContentWidth, + using: dependencies + ) generatedView.accessibilityIdentifier = accessory.accessibility?.identifier generatedView.accessibilityLabel = accessory.accessibility?.label addSubview(generatedView) diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 142f809a6..074f4a9f6 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -323,6 +323,7 @@ public class SessionCell: UITableViewCell { public func update( with info: Info, + tableSize: CGSize, isManualReload: Bool = false, using dependencies: Dependencies ) { @@ -339,48 +340,7 @@ public class SessionCell: UITableViewCell { let leadingFitToEdge: Bool = (info.leadingAccessory?.shouldFitToEdge == true) let trailingFitToEdge: Bool = (!leadingFitToEdge && info.trailingAccessory?.shouldFitToEdge == true) - // Content - contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing) - leadingAccessoryView.update( - with: info.leadingAccessory, - tintColor: info.styling.tintColor, - isEnabled: info.isEnabled, - isManualReload: isManualReload, - using: dependencies - ) - titleStackView.isHidden = (info.title == nil && info.subtitle == nil) - titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy) - titleLabel.font = info.title?.font - titleLabel.text = info.title?.text - titleLabel.themeTextColor = info.styling.tintColor - titleLabel.textAlignment = (info.title?.textAlignment ?? .left) - titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier - titleLabel.accessibilityLabel = info.title?.accessibility?.label - titleLabel.isHidden = (info.title == nil) - titleTextField.text = info.title?.text - titleTextField.textAlignment = (info.title?.textAlignment ?? .left) - titleTextField.placeholder = info.title?.editingPlaceholder - titleTextField.isHidden = (info.title == nil) - titleTextField.accessibilityIdentifier = info.title?.accessibility?.identifier - titleTextField.accessibilityLabel = info.title?.accessibility?.label - subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) - subtitleLabel.font = info.subtitle?.font - subtitleLabel.attributedText = info.subtitle.map { subtitle -> NSAttributedString? in - NSAttributedString(stringWithHTMLTags: subtitle.text, font: subtitle.font) - } - subtitleLabel.themeTextColor = info.styling.subtitleTintColor - subtitleLabel.textAlignment = (info.subtitle?.textAlignment ?? .left) - subtitleLabel.accessibilityIdentifier = info.subtitle?.accessibility?.identifier - subtitleLabel.accessibilityLabel = info.subtitle?.accessibility?.label - subtitleLabel.isHidden = (info.subtitle == nil) - trailingAccessoryView.update( - with: info.trailingAccessory, - tintColor: info.styling.tintColor, - isEnabled: info.isEnabled, - isManualReload: isManualReload, - using: dependencies - ) - + // Layout (do this before setting up the content so we can calculate the expected widths if needed) contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading) contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading) contentStackViewHorizontalCenterConstraint.constant = ((info.styling.customPadding?.leading ?? 0) + (info.styling.customPadding?.trailing ?? 0)) @@ -557,6 +517,54 @@ public class SessionCell: UITableViewCell { ) ) } + + // Content + let contentStackViewHorizontalInset: CGFloat = ( + (backgroundLeftConstraint.constant + (-backgroundRightConstraint.constant)) + + (contentStackViewLeadingConstraint.constant + (-contentStackViewTrailingConstraint.constant)) + ) + contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing) + leadingAccessoryView.update( + with: info.leadingAccessory, + tintColor: info.styling.tintColor, + isEnabled: info.isEnabled, + maxContentWidth: (tableSize.width - contentStackViewHorizontalInset), + isManualReload: isManualReload, + using: dependencies + ) + titleStackView.isHidden = (info.title == nil && info.subtitle == nil) + titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy) + titleLabel.font = info.title?.font + titleLabel.text = info.title?.text + titleLabel.themeTextColor = info.styling.tintColor + titleLabel.textAlignment = (info.title?.textAlignment ?? .left) + titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier + titleLabel.accessibilityLabel = info.title?.accessibility?.label + titleLabel.isHidden = (info.title == nil) + titleTextField.text = info.title?.text + titleTextField.textAlignment = (info.title?.textAlignment ?? .left) + titleTextField.placeholder = info.title?.editingPlaceholder + titleTextField.isHidden = (info.title == nil) + titleTextField.accessibilityIdentifier = info.title?.accessibility?.identifier + titleTextField.accessibilityLabel = info.title?.accessibility?.label + subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) + subtitleLabel.font = info.subtitle?.font + subtitleLabel.attributedText = info.subtitle.map { subtitle -> NSAttributedString? in + NSAttributedString(stringWithHTMLTags: subtitle.text, font: subtitle.font) + } + subtitleLabel.themeTextColor = info.styling.subtitleTintColor + subtitleLabel.textAlignment = (info.subtitle?.textAlignment ?? .left) + subtitleLabel.accessibilityIdentifier = info.subtitle?.accessibility?.identifier + subtitleLabel.accessibilityLabel = info.subtitle?.accessibility?.label + subtitleLabel.isHidden = (info.subtitle == nil) + trailingAccessoryView.update( + with: info.trailingAccessory, + tintColor: info.styling.tintColor, + isEnabled: info.isEnabled, + maxContentWidth: (tableSize.width - contentStackViewHorizontalInset), + isManualReload: isManualReload, + using: dependencies + ) } public func update(isEditing: Bool, becomeFirstResponder: Bool, animated: Bool) {