From 7085ac01cb32a92402fa3bd503d8a392e5bca06c Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 4 Oct 2022 00:02:41 -0400 Subject: [PATCH] Timeline status collection view cell collapsing --- Tusker.xcodeproj/project.pbxproj | 8 ++ Tusker/Extensions/UIView+Configure.swift | 22 +++++ .../Timeline/TimelineViewController.swift | 13 ++- .../Views/Status/StatusContentContainer.swift | 78 +++++++++++++++ .../TimelineStatusCollectionViewCell.swift | 95 +++++++++++-------- 5 files changed, 177 insertions(+), 39 deletions(-) create mode 100644 Tusker/Extensions/UIView+Configure.swift create mode 100644 Tusker/Views/Status/StatusContentContainer.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 95e60f88..091e73e9 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -217,6 +217,8 @@ D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; }; D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; }; D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; }; + D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */; }; + D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */; }; D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */; }; D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */; }; D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */; }; @@ -564,6 +566,8 @@ D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = ""; }; D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = ""; }; D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusCollectionViewCell.swift; sourceTree = ""; }; + D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentContainer.swift; sourceTree = ""; }; + D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Configure.swift"; sourceTree = ""; }; D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = ""; }; D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = ""; }; D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = ""; }; @@ -1025,6 +1029,7 @@ D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */, D62E9986279D094F00C26176 /* StatusMetaIndicatorsView.swift */, D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */, + D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */, ); path = Status; sourceTree = ""; @@ -1126,6 +1131,7 @@ D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */, D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */, D62E9984279CA23900C26176 /* URLSession+Development.swift */, + D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */, ); path = Extensions; sourceTree = ""; @@ -1745,6 +1751,7 @@ D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */, D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */, D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */, + D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */, D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */, D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */, D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */, @@ -1830,6 +1837,7 @@ D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */, D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */, D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */, + D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */, D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, diff --git a/Tusker/Extensions/UIView+Configure.swift b/Tusker/Extensions/UIView+Configure.swift new file mode 100644 index 00000000..98253851 --- /dev/null +++ b/Tusker/Extensions/UIView+Configure.swift @@ -0,0 +1,22 @@ +// +// UIView+Configure.swift +// Tusker +// +// Created by Shadowfacts on 10/2/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit + +protocol Configurable { + associatedtype T = Self + func configure(_ closure: (T) -> Void) -> T +} +extension Configurable where Self: UIView { + func configure(_ closure: (Self) -> Void) -> Self { + closure(self) + return self + } +} +extension UIView: Configurable { +} diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 4478b980..27c6f241 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -106,7 +106,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro let status = mastodonController.persistentContainer.status(for: id) else { fatalError() } - // TODO: update cell + cell.mastodonController = mastodonController + cell.delegate = self + cell.updateUI(statusID: id, state: state) } let timelineDescriptionCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in guard case .public(let local) = timeline else { @@ -328,3 +330,12 @@ extension TimelineViewController: UICollectionViewDelegate { } } } + +extension TimelineViewController: TimelineStatusCollectionViewCellDelegate { + func statusCellCollapsedStateChanged(_ cell: TimelineStatusCollectionViewCell) { + let indexPath = collectionView.indexPath(for: cell)! + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) + dataSource.apply(snapshot, animatingDifferences: true) + } +} diff --git a/Tusker/Views/Status/StatusContentContainer.swift b/Tusker/Views/Status/StatusContentContainer.swift new file mode 100644 index 00000000..9f41e7ea --- /dev/null +++ b/Tusker/Views/Status/StatusContentContainer.swift @@ -0,0 +1,78 @@ +// +// StatusContentContainer.swift +// Tusker +// +// Created by Shadowfacts on 10/2/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit + +class StatusContentContainer: UIView { + + let contentTextView = StatusContentTextView().configure { + $0.defaultFont = .systemFont(ofSize: 16) + $0.isScrollEnabled = false + $0.backgroundColor = nil + } + + let cardView = StatusCardView().configure { + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 65), + ]) + } + + let attachmentsView = AttachmentsContainerView().configure { + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalTo: $0.widthAnchor, multiplier: 9/16), + ]) + } + + let pollView = StatusPollView() + + private var lastSubviewBottomConstraint: NSLayoutConstraint! + private var zeroHeightConstraint: NSLayoutConstraint! + + override init(frame: CGRect) { + super.init(frame: frame) + + let subviews = [contentTextView, cardView, attachmentsView, pollView] + for (index, subview) in subviews.enumerated() { + subview.translatesAutoresizingMaskIntoConstraints = false + addSubview(subview) + + let topConstraint: NSLayoutConstraint + if index == 0 { + topConstraint = subview.topAnchor.constraint(equalTo: topAnchor) + } else { + topConstraint = subview.topAnchor.constraint(equalTo: subviews[index - 1].bottomAnchor, constant: 4) + } + + NSLayoutConstraint.activate([ + topConstraint, + subview.leadingAnchor.constraint(equalTo: leadingAnchor), + subview.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + // these constraints need to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands + lastSubviewBottomConstraint = subviews.last!.bottomAnchor.constraint(equalTo: bottomAnchor) + lastSubviewBottomConstraint.isActive = true + lastSubviewBottomConstraint.priority = .defaultLow + zeroHeightConstraint = heightAnchor.constraint(equalToConstant: 0) + zeroHeightConstraint.priority = .defaultLow + + // mask to bounds so that the during the expand/collapse animation, subviews are clipped + layer.masksToBounds = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setCollapsed(_ collapsed: Bool) { + lastSubviewBottomConstraint.isActive = !collapsed + zeroHeightConstraint.isActive = collapsed + } + +} diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index f98823e7..6f7fbfe0 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -7,6 +7,12 @@ // import UIKit +import Pachyderm + +@MainActor +protocol TimelineStatusCollectionViewCellDelegate: AnyObject { + func statusCellCollapsedStateChanged(_ cell: TimelineStatusCollectionViewCell) +} class TimelineStatusCollectionViewCell: UICollectionViewListCell { @@ -22,6 +28,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { private let reblogLabel = UILabel().configure { $0.textColor = .secondaryLabel + // this needs to have a higher priorty than the content container's zero height constraint + $0.setContentHuggingPriority(.defaultHigh, for: .vertical) // TODO: tap gesture // $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed))) } @@ -74,10 +82,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { nameHStack, contentWarningLabel, collapseButton, - contentTextView, - cardView, - attachmentsView, - pollView, + contentContainer, ]).configure { $0.axis = .vertical $0.spacing = 4 @@ -131,45 +136,43 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { ]), size: 0) } - private let contentWarningLabel = UILabel().configure { + private lazy var contentWarningLabel = UILabel().configure { $0.textColor = .secondaryLabel $0.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ .traits: [ UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold.rawValue, ] ]), size: 0) - // TODO: tap gesture -// $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed))) + // this needs to have a higher priorty than the content container's zero height constraint + $0.setContentHuggingPriority(.defaultHigh, for: .vertical) + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(collapseButtonPressed))) } - private let collapseButton = UIButton(configuration: { + private lazy var collapseButton = UIButton(configuration: { var config = UIButton.Configuration.filled() config.image = UIImage(systemName: "chevron.down") return config - }(), primaryAction: nil).configure { - _ = $0 + }()).configure { + $0.setContentHuggingPriority(.defaultHigh, for: .vertical) + $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) // TODO: masksToBounds and cornerRadius? } - private let contentTextView = StatusContentTextView().configure { - $0.defaultFont = .systemFont(ofSize: 16) - $0.isScrollEnabled = false - $0.backgroundColor = nil + private let contentContainer = StatusContentContainer().configure { + $0.setContentHuggingPriority(.defaultLow, for: .vertical) } - - private let cardView = StatusCardView().configure { - NSLayoutConstraint.activate([ - $0.heightAnchor.constraint(equalToConstant: 65), - ]) + private var contentTextView: StatusContentTextView { + contentContainer.contentTextView } - - private let attachmentsView = AttachmentsContainerView().configure { - NSLayoutConstraint.activate([ - $0.heightAnchor.constraint(equalTo: $0.widthAnchor, multiplier: 9/16), - ]) + private var cardView: StatusCardView { + contentContainer.cardView + } + private var attachmentsView: AttachmentsContainerView { + contentContainer.attachmentsView + } + private var pollView: StatusPollView { + contentContainer.pollView } - - private let pollView = StatusPollView() private var placeholderReplyButtonLeadingConstraint: NSLayoutConstraint! private lazy var actionsContainer = UIView().configure { @@ -224,6 +227,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { $0.showsMenuAsPrimaryAction = true } + weak var mastodonController: MastodonController! + weak var delegate: TimelineStatusCollectionViewCellDelegate? + + private(set) var statusID: String? + private(set) var statusState = StatusState.unknown + private var firstLayout = true override init(frame: CGRect) { @@ -273,17 +282,27 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell { } } -} - -fileprivate protocol Configurable { - associatedtype T = Self - func configure(_ closure: (T) -> Void) -> T -} -extension Configurable where Self: UIView { - func configure(_ closure: (Self) -> Void) -> Self { - closure(self) - return self + func updateUI(statusID: String, state: StatusState) { + guard let status = mastodonController.persistentContainer.status(for: statusID) else { + fatalError() + } + self.statusID = statusID + self.statusState = state + // TODO: remove this hack + state.collapsible = true + state.collapsed = state.collapsed ?? false +// state.resolveFor(status: status, text: "") + print("updateUI setting collapsed to: \(state.collapsed!)") + contentContainer.setCollapsed(state.collapsed!) + collapseButton.imageView!.transform = CGAffineTransform(rotationAngle: state.collapsed! ? .pi : 0) } -} -extension UIView: Configurable { + + // MARK: Interaction + + @objc func collapseButtonPressed() { + statusState.collapsed!.toggle() + // this delegate call causes the collection view to reconfigure this cell, at which point (and inside of the collection view's animation handling) we'll update the contentContainer + delegate?.statusCellCollapsedStateChanged(self) + } + }