mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
281 lines
9.9 KiB
Swift
281 lines
9.9 KiB
Swift
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
final class AppIconGridView: UIView {
|
|
public static let size: SessionCell.Accessory.Size = .fillWidthWrapHeight
|
|
|
|
/// 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.expectedMinSize)
|
|
private var iconViewTopConstraints: [NSLayoutConstraint] = []
|
|
private var iconViewLeadingConstraints: [NSLayoutConstraint] = []
|
|
private var iconViewWidthConstraints: [NSLayoutConstraint] = []
|
|
|
|
private let contentView: UIView = UIView()
|
|
|
|
private lazy var iconViews: [IconView] = icons.map { icon in
|
|
IconView(icon: icon) { [weak self] in self?.onChange?(icon) }
|
|
}
|
|
|
|
// MARK: - Initializtion
|
|
|
|
init(maxContentWidth: CGFloat) {
|
|
self.maxContentWidth = maxContentWidth
|
|
|
|
super.init(frame: .zero)
|
|
|
|
setupUI()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("Use init(theme:) instead")
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
private func setupUI() {
|
|
addSubview(contentView)
|
|
|
|
iconViews.forEach { contentView.addSubview($0) }
|
|
|
|
setupLayout()
|
|
}
|
|
|
|
private func setupLayout() {
|
|
contentView.pin(to: self)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Fallback to the min sizes to prevent a future change resulting in a `0` value
|
|
return (IconView.expectedMinSize, minSpacing)
|
|
}
|
|
|
|
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 }
|
|
|
|
/// Calculate the position the next `IconView` should have
|
|
nextX += (targetSize + targetSpacing)
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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() {
|
|
super.layoutSubviews()
|
|
|
|
/// Only bother laying out if we haven't already done so
|
|
guard
|
|
!iconViewTopConstraints.contains(where: { $0.constant > 0 }) ||
|
|
!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
|
|
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 = frames[index].minY
|
|
iconViewLeadingConstraints[index].constant = frames[index].minX
|
|
iconViewWidthConstraints[index].constant = frames[index].width
|
|
|
|
UIView.performWithoutAnimation { iconView.layoutIfNeeded() }
|
|
}
|
|
|
|
contentViewViewHeightConstraint.constant = frames
|
|
.reduce(0) { result, next -> CGFloat in max(result, next.maxY) }
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
fileprivate func update(with selectedIcon: AppIcon?, onChange: @escaping (AppIcon) -> ()) {
|
|
self.onChange = onChange
|
|
|
|
iconViews.enumerated().forEach { index, iconView in
|
|
iconView.update(isSelected: (icons[index] == selectedIcon))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Info
|
|
|
|
extension AppIconGridView: SessionCell.Accessory.CustomView {
|
|
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
|
|
typealias View = AppIconGridView
|
|
|
|
let selectedIcon: AppIcon?
|
|
let onChange: (AppIcon) -> ()
|
|
|
|
static func == (lhs: Info, rhs: Info) -> Bool {
|
|
return (lhs.selectedIcon == rhs.selectedIcon)
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
selectedIcon.hash(into: &hasher)
|
|
}
|
|
}
|
|
|
|
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> AppIconGridView {
|
|
return AppIconGridView(maxContentWidth: maxContentWidth)
|
|
}
|
|
|
|
func update(with info: Info) {
|
|
update(with: info.selectedIcon, onChange: info.onChange)
|
|
}
|
|
}
|
|
|
|
// MARK: - IconView
|
|
|
|
extension AppIconGridView {
|
|
class IconView: UIView {
|
|
fileprivate static let minImageSize: CGFloat = 85
|
|
fileprivate static let selectionInset: CGFloat = 4
|
|
fileprivate static var expectedMinSize: CGFloat = (minImageSize + (selectionInset * 2))
|
|
|
|
private let onSelected: () -> ()
|
|
|
|
// MARK: - Components
|
|
|
|
private lazy var backgroundButton: UIButton = UIButton(
|
|
type: .custom,
|
|
primaryAction: UIAction(handler: { [weak self] _ in
|
|
self?.onSelected()
|
|
})
|
|
)
|
|
|
|
private let selectionBorderView: UIView = {
|
|
let result: UIView = UIView()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.isUserInteractionEnabled = false
|
|
result.themeBorderColor = .radioButton_selectedBorder
|
|
result.layer.borderWidth = 2
|
|
result.layer.cornerRadius = 21
|
|
result.isHidden = true
|
|
|
|
return result
|
|
}()
|
|
|
|
private let imageView: UIImageView = {
|
|
let result: UIImageView = UIImageView()
|
|
result.translatesAutoresizingMaskIntoConstraints = false
|
|
result.isUserInteractionEnabled = false
|
|
result.contentMode = .scaleAspectFit
|
|
result.layer.cornerRadius = 16
|
|
result.clipsToBounds = true
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - Initializtion
|
|
|
|
init(icon: AppIcon, onSelected: @escaping () -> ()) {
|
|
self.onSelected = onSelected
|
|
|
|
super.init(frame: .zero)
|
|
|
|
setupUI(icon: icon)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("Use init(color:) instead")
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
private func setupUI(icon: AppIcon) {
|
|
imageView.image = UIImage(named: icon.previewImageName)
|
|
|
|
addSubview(backgroundButton)
|
|
addSubview(selectionBorderView)
|
|
addSubview(imageView)
|
|
|
|
setupLayout()
|
|
}
|
|
|
|
private func setupLayout() {
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
backgroundButton.pin(to: self)
|
|
|
|
selectionBorderView.pin(to: self)
|
|
|
|
imageView.pin(to: selectionBorderView, withInset: IconView.selectionInset)
|
|
imageView.set(.height, to: .width, of: imageView)
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
func update(isSelected: Bool) {
|
|
selectionBorderView.isHidden = !isSelected
|
|
}
|
|
}
|
|
}
|