// // 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 } }