220 lines
7.7 KiB
Swift
220 lines
7.7 KiB
Swift
//
|
|
// StatusContentContainer.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 10/2/22.
|
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
class StatusContentContainer: UIView {
|
|
// TODO: this is a weird place for this
|
|
static var cardViewHeight: CGFloat { 90 }
|
|
|
|
private var arrangedSubviews: [any StatusContentView]
|
|
|
|
private var isHiddenObservations: [NSKeyValueObservation] = []
|
|
|
|
private var visibleSubviews = IndexSet()
|
|
private var verticalConstraints: [NSLayoutConstraint] = []
|
|
private var lastSubviewBottomConstraint: (UIView, NSLayoutConstraint)?
|
|
private var zeroHeightConstraint: NSLayoutConstraint!
|
|
|
|
private var isCollapsed = false
|
|
|
|
var visibleSubviewHeight: CGFloat {
|
|
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
|
|
}
|
|
|
|
init(arrangedSubviews: [any StatusContentView], useTopSpacer: Bool) {
|
|
var arrangedSubviews = arrangedSubviews
|
|
if useTopSpacer {
|
|
arrangedSubviews.insert(TopSpacerView(), at: 0)
|
|
}
|
|
self.arrangedSubviews = arrangedSubviews
|
|
|
|
super.init(frame: .zero)
|
|
|
|
for subview in arrangedSubviews {
|
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(subview)
|
|
|
|
if subview.statusContentFillsHorizontally {
|
|
NSLayoutConstraint.activate([
|
|
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
])
|
|
} else {
|
|
subview.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
|
}
|
|
}
|
|
|
|
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
|
|
zeroHeightConstraint = heightAnchor.constraint(equalToConstant: 0)
|
|
zeroHeightConstraint.priority = .defaultLow
|
|
|
|
setNeedsUpdateConstraints()
|
|
|
|
updateObservations()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func updateObservations() {
|
|
isHiddenObservations = arrangedSubviews.map {
|
|
$0.observeIsHidden { [unowned self] in
|
|
self.setNeedsUpdateConstraints()
|
|
}
|
|
}
|
|
}
|
|
|
|
override func updateConstraints() {
|
|
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
|
|
if self.visibleSubviews != visibleSubviews {
|
|
self.visibleSubviews = visibleSubviews
|
|
NSLayoutConstraint.deactivate(verticalConstraints)
|
|
verticalConstraints = []
|
|
var lastVisibleSubview: UIView?
|
|
|
|
for subviewIndex in visibleSubviews {
|
|
let subview = arrangedSubviews[subviewIndex]
|
|
if let lastVisibleSubview {
|
|
verticalConstraints.append(subview.topAnchor.constraint(equalTo: lastVisibleSubview.bottomAnchor, constant: 4))
|
|
} else {
|
|
verticalConstraints.append(subview.topAnchor.constraint(equalTo: topAnchor))
|
|
}
|
|
lastVisibleSubview = subview
|
|
}
|
|
|
|
NSLayoutConstraint.activate(verticalConstraints)
|
|
}
|
|
|
|
if lastSubviewBottomConstraint == nil || arrangedSubviews[visibleSubviews.last!] !== lastSubviewBottomConstraint?.0 {
|
|
lastSubviewBottomConstraint?.1.isActive = false
|
|
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
|
|
let lastVisibleSubview = arrangedSubviews[visibleSubviews.last!]
|
|
let constraint = lastVisibleSubview.bottomAnchor.constraint(equalTo: bottomAnchor)
|
|
constraint.isActive = !isCollapsed
|
|
constraint.priority = .defaultLow
|
|
lastSubviewBottomConstraint = (lastVisibleSubview, constraint)
|
|
}
|
|
|
|
zeroHeightConstraint.isActive = isCollapsed
|
|
|
|
super.updateConstraints()
|
|
}
|
|
|
|
func insertArrangedSubview(_ view: any StatusContentView, after: any StatusContentView) {
|
|
view.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(view)
|
|
if view.statusContentFillsHorizontally {
|
|
NSLayoutConstraint.activate([
|
|
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
])
|
|
} else {
|
|
view.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
|
}
|
|
|
|
let index = arrangedSubviews.firstIndex(where: { $0 === after })!
|
|
arrangedSubviews.insert(view, at: index + 1)
|
|
setNeedsUpdateConstraints()
|
|
updateObservations()
|
|
}
|
|
|
|
func removeArrangedSubview(_ view: any StatusContentView) {
|
|
view.removeFromSuperview()
|
|
arrangedSubviews.removeAll(where: { $0 === view })
|
|
setNeedsUpdateConstraints()
|
|
updateObservations()
|
|
}
|
|
|
|
func setCollapsed(_ collapsed: Bool) {
|
|
guard collapsed != isCollapsed else {
|
|
return
|
|
}
|
|
isCollapsed = collapsed
|
|
|
|
// don't call setNeedsUpdateConstraints b/c that destroys/recreates a bunch of other constraints
|
|
// if there is no lastSubviewBottomConstraint, then we already need a constraint update, so we don't need to do anything here
|
|
if let lastSubviewBottomConstraint {
|
|
lastSubviewBottomConstraint.1.isActive = !collapsed
|
|
zeroHeightConstraint.isActive = collapsed
|
|
}
|
|
}
|
|
|
|
// used only for collapsing automatically based on height, doesn't need to be accurate
|
|
// just roughly inline with the content height
|
|
func estimateVisibleSubviewHeight(effectiveWidth: CGFloat) -> CGFloat {
|
|
var height: CGFloat = 0
|
|
for view in arrangedSubviews where !view.isHidden {
|
|
height += view.estimateHeight(effectiveWidth: effectiveWidth)
|
|
}
|
|
return height
|
|
}
|
|
|
|
}
|
|
|
|
extension StatusContentContainer {
|
|
private class TopSpacerView: UIView, StatusContentView {
|
|
init() {
|
|
super.init(frame: .zero)
|
|
|
|
backgroundColor = .clear
|
|
// other 4pt is provided by this view's own spacing
|
|
heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
|
4
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIView {
|
|
func observeIsHidden(_ f: @escaping @Sendable @MainActor () -> Void) -> NSKeyValueObservation {
|
|
self.observe(\.isHidden) { _, _ in
|
|
MainActor.runUnsafely {
|
|
f()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
protocol StatusContentView: UIView {
|
|
var statusContentFillsHorizontally: Bool { get }
|
|
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat
|
|
}
|
|
|
|
extension StatusContentView {
|
|
var statusContentFillsHorizontally: Bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
extension ContentTextView: StatusContentView {
|
|
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
|
sizeThatFits(CGSize(width: effectiveWidth, height: UIView.layoutFittingCompressedSize.height)).height
|
|
}
|
|
}
|
|
|
|
extension StatusCardView: StatusContentView {
|
|
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
|
StatusContentContainer.cardViewHeight
|
|
}
|
|
}
|
|
|
|
extension AttachmentsContainerView: StatusContentView {
|
|
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
|
effectiveWidth / aspectRatio
|
|
}
|
|
}
|