Updated the layout logic for the AppIconGridView

pull/1061/head
Morgan Pretty 1 month ago
parent 86b9e8256e
commit 15245c15c8

@ -276,6 +276,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
identifier: "Contact" identifier: "Contact"
) )
), ),
tableSize: tableView.bounds.size,
using: dependencies using: dependencies
) )

@ -468,6 +468,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
isEnabled: (authorId == self.messageViewModel.currentUserSessionId) isEnabled: (authorId == self.messageViewModel.currentUserSessionId)
), ),
tableSize: tableView.bounds.size,
using: dependencies using: dependencies
) )

@ -120,7 +120,7 @@ final class PathStatusViewAccessory: UIView, SessionCell.Accessory.CustomView {
height: IconSize.medium.size height: IconSize.medium.size
) )
static func create(using dependencies: Dependencies) -> PathStatusViewAccessory { static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> PathStatusViewAccessory {
return PathStatusViewAccessory(using: dependencies) return PathStatusViewAccessory(using: dependencies)
} }

@ -11,13 +11,15 @@ final class AppIconGridView: UIView {
/// Excluding the default icon /// Excluding the default icon
private var icons: [AppIcon] = AppIcon.allCases.filter { $0 != .session } private var icons: [AppIcon] = AppIcon.allCases.filter { $0 != .session }
private var onChange: ((AppIcon) -> ())? private var onChange: ((AppIcon) -> ())?
private let maxContentWidth: CGFloat
// MARK: - Components // MARK: - Components
lazy var contentViewViewHeightConstraint: NSLayoutConstraint = contentView.heightAnchor lazy var contentViewViewHeightConstraint: NSLayoutConstraint = contentView.heightAnchor
.constraint(equalToConstant: IconView.expectedSize) .constraint(equalToConstant: IconView.expectedMinSize)
private var iconViewTopConstraints: [NSLayoutConstraint] = [] private var iconViewTopConstraints: [NSLayoutConstraint] = []
private var iconViewLeadingConstraints: [NSLayoutConstraint] = [] private var iconViewLeadingConstraints: [NSLayoutConstraint] = []
private var iconViewWidthConstraints: [NSLayoutConstraint] = []
private let contentView: UIView = UIView() private let contentView: UIView = UIView()
@ -27,7 +29,9 @@ final class AppIconGridView: UIView {
// MARK: - Initializtion // MARK: - Initializtion
init() { init(maxContentWidth: CGFloat) {
self.maxContentWidth = maxContentWidth
super.init(frame: .zero) super.init(frame: .zero)
setupUI() setupUI()
@ -53,29 +57,68 @@ final class AppIconGridView: UIView {
iconViews.forEach { iconViews.forEach {
iconViewTopConstraints.append($0.pin(.top, to: .top, of: contentView)) iconViewTopConstraints.append($0.pin(.top, to: .top, of: contentView))
iconViewLeadingConstraints.append($0.pin(.leading, to: .leading, of: contentView)) iconViewLeadingConstraints.append($0.pin(.leading, to: .leading, of: contentView))
iconViewWidthConstraints.append($0.set(.width, to: IconView.minImageSize))
}
} }
// iconViews.last?.pin(.bottom, to: .bottom, of: contentView) /// 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)
}
} }
override var intrinsicContentSize: CGSize { /// Fallback to the min sizes to prevent a future change resulting in a `0` value
var x: CGFloat = 0 return (IconView.expectedMinSize, minSpacing)
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 } 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 { /// If the end of the next icon would go past the `maxContentWidth` then wrap to the next line
x = 0 if nextX + targetSize > maxContentWidth {
result = (result + IconView.expectedSize + Values.smallSpacing) nextX = 0
nextY += (targetSize + targetSpacing)
}
} }
} }
return CGSize( override var intrinsicContentSize: CGSize {
width: UIView.noIntrinsicMetric, return calculateIconViewFrames().reduce(.zero) { result, next -> CGSize in
height: (expectedHeight + IconView.expectedSize) CGSize(width: max(result.width, next.maxX), height: max(result.height, next.maxY))
) }
} }
override func layoutSubviews() { override func layoutSubviews() {
@ -87,31 +130,28 @@ final class AppIconGridView: UIView {
!iconViewLeadingConstraints.contains(where: { $0.constant > 0 }) !iconViewLeadingConstraints.contains(where: { $0.constant > 0 })
else { return } else { return }
/// We manually layout the `IconView` instances because it's easier than trying to get /// We manually layout the `IconView` instances because it's easier than trying to get a good "overflow" behaviour doing it
/// a good "overflow" behaviour doing it manually than using existing UI elements /// manually than using existing UI elements
var targetX: CGFloat = 0 let frames: [CGRect] = calculateIconViewFrames()
var targetY: CGFloat = 0
/// 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 iconViews.enumerated().forEach { index, iconView in
iconViewTopConstraints[index].constant = targetY iconViewTopConstraints[index].constant = frames[index].minY
iconViewLeadingConstraints[index].constant = targetX iconViewLeadingConstraints[index].constant = frames[index].minX
iconViewWidthConstraints[index].constant = frames[index].width
UIView.performWithoutAnimation { iconView.layoutIfNeeded() } 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 // MARK: - Content
@ -143,8 +183,8 @@ extension AppIconGridView: SessionCell.Accessory.CustomView {
} }
} }
static func create(using dependencies: Dependencies) -> AppIconGridView { static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> AppIconGridView {
return AppIconGridView() return AppIconGridView(maxContentWidth: maxContentWidth)
} }
func update(with info: Info) { func update(with info: Info) {
@ -156,9 +196,9 @@ extension AppIconGridView: SessionCell.Accessory.CustomView {
extension AppIconGridView { extension AppIconGridView {
class IconView: UIView { class IconView: UIView {
fileprivate static let imageSize: CGFloat = 85 fileprivate static let minImageSize: CGFloat = 85
fileprivate static let selectionInset: CGFloat = 4 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: () -> () private let onSelected: () -> ()
@ -189,6 +229,7 @@ extension AppIconGridView {
result.isUserInteractionEnabled = false result.isUserInteractionEnabled = false
result.contentMode = .scaleAspectFit result.contentMode = .scaleAspectFit
result.layer.cornerRadius = 16 result.layer.cornerRadius = 16
result.clipsToBounds = true
return result return result
}() }()
@ -227,8 +268,7 @@ extension AppIconGridView {
selectionBorderView.pin(to: self) selectionBorderView.pin(to: self)
imageView.pin(to: selectionBorderView, withInset: IconView.selectionInset) imageView.pin(to: selectionBorderView, withInset: IconView.selectionInset)
imageView.set(.width, to: IconView.imageSize) imageView.set(.height, to: .width, of: imageView)
imageView.set(.height, to: IconView.imageSize)
} }
// MARK: - Content // MARK: - Content

@ -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() return PrimaryColorSelectionView()
} }

@ -106,7 +106,7 @@ extension ThemeMessagePreviewView: SessionCell.Accessory.CustomView {
typealias View = ThemeMessagePreviewView typealias View = ThemeMessagePreviewView
} }
static func create(using dependencies: Dependencies) -> ThemeMessagePreviewView { static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemeMessagePreviewView {
return ThemeMessagePreviewView(using: dependencies) return ThemeMessagePreviewView(using: dependencies)
} }

@ -87,7 +87,7 @@ extension ThemePreviewView: SessionCell.Accessory.CustomView {
let theme: Theme let theme: Theme
} }
static func create(using dependencies: Dependencies) -> ThemePreviewView { static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemePreviewView {
return ThemePreviewView() return ThemePreviewView()
} }

@ -454,7 +454,7 @@ class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITa
switch (cell, info) { switch (cell, info) {
case (let cell as SessionCell, _): 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( cell.update(
isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)), isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
becomeFirstResponder: false, becomeFirstResponder: false,
@ -675,7 +675,12 @@ class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITa
) { ) {
// Try update the existing cell to have a nice animation instead of reloading the cell // 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 { 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 { else {
tableView.reloadRows(at: [indexPath], with: .none) tableView.reloadRows(at: [indexPath], with: .none)

@ -697,8 +697,8 @@ public extension SessionCell.AccessoryConfig {
// MARK: - Conformance // MARK: - Conformance
public func createView(using dependencies: Dependencies) -> UIView { public func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView {
return info.createView(using: dependencies) return info.createView(maxContentWidth: maxContentWidth, using: dependencies)
} }
override public func hash(into hasher: inout Hasher) { override public func hash(into hasher: inout Hasher) {
@ -718,7 +718,7 @@ public extension SessionCell.AccessoryConfig {
protocol AnyCustom { protocol AnyCustom {
var accessibility: Accessibility? { get } 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 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) func update(with info: Info)
} }
@ -748,8 +748,8 @@ public extension SessionCell.Accessory {
} }
public extension SessionCell.Accessory.CustomViewInfo { public extension SessionCell.Accessory.CustomViewInfo {
func createView(using dependencies: Dependencies) -> UIView { func createView(maxContentWidth: CGFloat, using dependencies: Dependencies) -> UIView {
let view: View = View.create(using: dependencies) let view: View = View.create(maxContentWidth: maxContentWidth, using: dependencies)
view.update(with: self) view.update(with: self)
switch View.size { switch View.size {

@ -289,6 +289,7 @@ extension SessionCell {
with accessory: Accessory?, with accessory: Accessory?,
tintColor: ThemeValue, tintColor: ThemeValue,
isEnabled: Bool, isEnabled: Bool,
maxContentWidth: CGFloat,
isManualReload: Bool, isManualReload: Bool,
using dependencies: Dependencies using dependencies: Dependencies
) { ) {
@ -594,7 +595,10 @@ extension SessionCell {
// MARK: -- Custom // MARK: -- Custom
case let accessory as SessionCell.AccessoryConfig.AnyCustom: 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.accessibilityIdentifier = accessory.accessibility?.identifier
generatedView.accessibilityLabel = accessory.accessibility?.label generatedView.accessibilityLabel = accessory.accessibility?.label
addSubview(generatedView) addSubview(generatedView)

@ -323,6 +323,7 @@ public class SessionCell: UITableViewCell {
public func update<ID: Hashable & Differentiable>( public func update<ID: Hashable & Differentiable>(
with info: Info<ID>, with info: Info<ID>,
tableSize: CGSize,
isManualReload: Bool = false, isManualReload: Bool = false,
using dependencies: Dependencies using dependencies: Dependencies
) { ) {
@ -339,48 +340,7 @@ public class SessionCell: UITableViewCell {
let leadingFitToEdge: Bool = (info.leadingAccessory?.shouldFitToEdge == true) let leadingFitToEdge: Bool = (info.leadingAccessory?.shouldFitToEdge == true)
let trailingFitToEdge: Bool = (!leadingFitToEdge && info.trailingAccessory?.shouldFitToEdge == true) let trailingFitToEdge: Bool = (!leadingFitToEdge && info.trailingAccessory?.shouldFitToEdge == true)
// Content // Layout (do this before setting up the content so we can calculate the expected widths if needed)
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
)
contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading) contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading)
contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading) contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading)
contentStackViewHorizontalCenterConstraint.constant = ((info.styling.customPadding?.leading ?? 0) + (info.styling.customPadding?.trailing ?? 0)) 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) { public func update(isEditing: Bool, becomeFirstResponder: Bool, animated: Bool) {

Loading…
Cancel
Save