Tusker/Tusker/Views/Status/StatusContentContainer.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
}
}