Actual gap cell implementation

This commit is contained in:
Shadowfacts 2022-11-19 11:15:14 -05:00
parent 0fddf94292
commit ce534c4a05
5 changed files with 206 additions and 54 deletions

View File

@ -99,6 +99,11 @@
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "CG_CONTEXT_SHOW_BACKTRACE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "CG_NUMERICS_SHOW_BACKTRACE"
value = ""

View File

@ -10,36 +10,62 @@ import UIKit
class TimelineGapCollectionViewCell: UICollectionViewCell {
var fillGap: ((TimelineGapDirection) -> Void)?
private(set) var direction = TimelineGapDirection.above
private let indicator = UIActivityIndicatorView(style: .medium)
private let chevronView = AnimatingChevronView()
override var isHighlighted: Bool {
didSet {
backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground
}
}
var showsIndicator: Bool = false {
didSet {
if showsIndicator {
indicator.isHidden = false
indicator.startAnimating()
} else {
indicator.isHidden = true
indicator.stopAnimating()
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .red
backgroundColor = .systemGroupedBackground
var belowConfig = UIButton.Configuration.borderless()
belowConfig.title = "Below"
let below = UIButton(configuration: belowConfig)
below.addTarget(self, action: #selector(loadBelowPressed), for: .touchUpInside)
indicator.isHidden = true
indicator.color = .tintColor
var aboveConfig = UIButton.Configuration.borderless()
aboveConfig.title = "Above"
let above = UIButton(configuration: aboveConfig)
above.addTarget(self, action: #selector(loadAbovePressed), for: .touchUpInside)
let label = UILabel()
label.text = "Load more"
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .tintColor
chevronView.update(direction: .above)
let stack = UIStackView(arrangedSubviews: [
above,
below,
label,
chevronView,
])
stack.axis = .vertical
stack.spacing = 4
stack.axis = .horizontal
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
indicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(indicator)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
indicator.trailingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -8),
indicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
contentView.heightAnchor.constraint(equalToConstant: 44),
])
}
@ -47,12 +73,92 @@ class TimelineGapCollectionViewCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented")
}
@objc private func loadBelowPressed() {
fillGap?(.below)
override func didMoveToSuperview() {
super.didMoveToSuperview()
update()
}
@objc private func loadAbovePressed() {
fillGap?(.above)
func update() {
guard let superview = superview as? UICollectionView else {
return
}
let yInParent = frame.minY - superview.contentOffset.y
let centerInParent = yInParent + bounds.height / 2
let centerOfParent = superview.frame.height / 2
let newDirection: TimelineGapDirection
if centerInParent > centerOfParent {
newDirection = .above
} else {
newDirection = .below
}
if newDirection != direction {
direction = newDirection
chevronView.update(direction: newDirection)
}
}
}
private class AnimatingChevronView: UIView {
override class var layerClass: AnyClass { CAShapeLayer.self }
var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
override var intrinsicContentSize: CGSize { CGSize(width: 20, height: 25) }
var animator: UIViewPropertyAnimator?
let upPath: CGPath = {
let path = UIBezierPath()
let width: CGFloat = 20
let height: CGFloat = 25
path.move(to: CGPoint(x: 0, y: height / 2))
path.addLine(to: CGPoint(x: width / 2, y: height / 5))
path.addLine(to: CGPoint(x: width, y: height / 2))
return path.cgPath
}()
let downPath: CGPath = {
let path = UIBezierPath()
let width: CGFloat = 20
let height: CGFloat = 25
path.move(to: CGPoint(x: 0, y: height / 2))
path.addLine(to: CGPoint(x: width / 2, y: 4 * height / 5))
path.addLine(to: CGPoint(x: width, y: height / 2))
return path.cgPath
}()
init() {
super.init(frame: .zero)
shapeLayer.fillColor = nil
shapeLayer.strokeColor = tintColor.cgColor
shapeLayer.lineCap = .round
shapeLayer.lineJoin = .round
shapeLayer.lineWidth = 3
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func tintColorDidChange() {
super.tintColorDidChange()
shapeLayer.strokeColor = tintColor.cgColor
}
func update(direction: TimelineGapDirection) {
if animator?.isRunning == true {
animator!.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
if direction == .below {
self.shapeLayer.path = self.upPath
} else {
self.shapeLayer.path = self.downPath
}
}
animator!.startAnimation()
}
}

View File

@ -22,6 +22,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var contentOffsetObservation: NSKeyValueObservation?
init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline
self.mastodonController = mastodonController
@ -56,8 +58,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
}
if case .status(_, _) = item {
} else {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
@ -87,34 +88,43 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
#endif
#if DEBUG
// navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in
// var snapshot = self.dataSource.snapshot()
// let statuses = snapshot.itemIdentifiers(inSection: .statuses)
// if statuses.count > 20 {
// let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
// if !toRemove.isEmpty {
// print("REMOVING MIDDLE \(toRemove.count) STATUSES")
// snapshot.insertItems([.gap], beforeItem: toRemove.first!)
// snapshot.deleteItems(toRemove)
// self.dataSource.apply(snapshot)
// }
// }
// }))
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in
let split = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in
var snapshot = self.dataSource.snapshot()
let statuses = snapshot.itemIdentifiers(inSection: .statuses)
if statuses.count > 20 {
let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
if !toRemove.isEmpty {
print("REMOVING MIDDLE \(toRemove.count) STATUSES")
snapshot.insertItems([.gap], beforeItem: toRemove.first!)
snapshot.deleteItems(toRemove)
self.dataSource.apply(snapshot)
}
}
}))
let rewind = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in
var snapshot = self.dataSource.snapshot()
let statuses = snapshot.itemIdentifiers(inSection: .statuses)
if statuses.count > 20 {
let toRemove = Array(statuses.dropLast(20))
snapshot.deleteItems(toRemove)
self.dataSource.apply(snapshot)
// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).first {
// self.controller.dataSource.state.newer = .after(id: id, count: nil)
// }
}
}))
if #available(iOS 16.0, *) {
navigationItem.trailingItemGroups = [
UIBarButtonItemGroup(barButtonItems: [split, rewind], representativeItem: nil)
]
}
#else
#error("remove me")
#endif
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
if let indexPath = self?.dataSource.indexPath(for: .gap),
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
cell.update()
}
}
}
// separate method because InstanceTimelineViewController needs to be able to customize it
@ -127,12 +137,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1)
}
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
cell.fillGap = { [unowned self] direction in
Task {
await self.controller.fillGap(in: direction)
}
}
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
cell.showsIndicator = false
}
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
guard case .public(let local) = timeline else {
@ -276,7 +282,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
var config = ToastConfiguration(title: "Jump to present")
config.edge = .top
config.systemImageName = "arrow.up"
config.dismissAutomaticallyAfter = 2
config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
@ -359,7 +365,7 @@ extension TimelineViewController {
var isSelectable: Bool {
switch self {
case .publicTimelineDescription, .status(id: _, state: _):
case .publicTimelineDescription, .gap, .status(id: _, state: _):
return true
default:
return false
@ -519,6 +525,8 @@ extension TimelineViewController {
if indexOfFirstTimelineItemExistingBelowGap != nil {
snapshot.deleteItems([.gap])
}
await apply(snapshot, animatingDifferences: !addedItems)
case .below:
let beforeGap = statusItems[..<gapIndex].suffix(20)
@ -551,6 +559,34 @@ extension TimelineViewController {
if indexOfLastTimelineItemExistingAboveGap != nil {
snapshot.deleteItems([.gap])
}
if addedItems {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
// Yes, these are all load bearing. Setting the contentOffset seems to cause the collection view to recalculate the size
// of some cells, thus changing the contentSize and the offset necessary to match to match the bottom offset.
// Three DispatchQueue.main.async's seems to be the fewest we can reliably get away with.
let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y
dataSource.apply(snapshot, animatingDifferences: false) {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
DispatchQueue.main.async {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
DispatchQueue.main.async {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
DispatchQueue.main.async {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
snapshotView.removeFromSuperview()
}
}
}
}
} else {
dataSource.apply(snapshot, animatingDifferences: true) {}
}
}
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
@ -559,8 +595,6 @@ extension TimelineViewController {
config.dismissAutomaticallyAfter = 2
showToast(configuration: config, animated: true)
}
await apply(snapshot, animatingDifferences: true)
}
enum Error: TimelineLikeCollectionViewError {
@ -601,7 +635,14 @@ extension TimelineViewController: UICollectionViewDelegate {
let status = mastodonController.persistentContainer.status(for: id)!
// if the status in the timeline is a reblog, show the status that it is a reblog of
selected(status: status.reblog?.id ?? id, state: state.copy())
case .gap, .loadingIndicator, .confirmLoadMore:
case .gap:
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true
Task {
await controller.fillGap(in: cell.direction)
cell.showsIndicator = false
}
case .loadingIndicator, .confirmLoadMore:
fatalError("unreachable")
}
}

View File

@ -206,7 +206,7 @@ class TimelineLikeController<Item> {
case .loadingOlder(let token, let hasAddedLoadingIndicator):
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingGap(let token, let direction):
return "loadingGap(\(ObjectIdentifier(token)), \(direction)"
return "loadingGap(\(ObjectIdentifier(token)), \(direction))"
}
}

View File

@ -28,7 +28,7 @@ class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .secondarySystemBackground
backgroundColor = .systemGroupedBackground
let label = UILabel()
label.text = "Infinite scrolling is off. Do you want to keep going?"