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" value = "1"
isEnabled = "NO"> isEnabled = "NO">
</EnvironmentVariable> </EnvironmentVariable>
<EnvironmentVariable
key = "CG_CONTEXT_SHOW_BACKTRACE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable <EnvironmentVariable
key = "CG_NUMERICS_SHOW_BACKTRACE" key = "CG_NUMERICS_SHOW_BACKTRACE"
value = "" value = ""

View File

@ -10,36 +10,62 @@ import UIKit
class TimelineGapCollectionViewCell: UICollectionViewCell { 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) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .red backgroundColor = .systemGroupedBackground
var belowConfig = UIButton.Configuration.borderless() indicator.isHidden = true
belowConfig.title = "Below" indicator.color = .tintColor
let below = UIButton(configuration: belowConfig)
below.addTarget(self, action: #selector(loadBelowPressed), for: .touchUpInside)
var aboveConfig = UIButton.Configuration.borderless() let label = UILabel()
aboveConfig.title = "Above" label.text = "Load more"
let above = UIButton(configuration: aboveConfig) label.font = .preferredFont(forTextStyle: .headline)
above.addTarget(self, action: #selector(loadAbovePressed), for: .touchUpInside) label.textColor = .tintColor
chevronView.update(direction: .above)
let stack = UIStackView(arrangedSubviews: [ let stack = UIStackView(arrangedSubviews: [
above, label,
below, chevronView,
]) ])
stack.axis = .vertical stack.axis = .horizontal
stack.spacing = 4 stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack) contentView.addSubview(stack)
indicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(indicator)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
stack.topAnchor.constraint(equalTo: contentView.topAnchor), indicator.trailingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -8),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 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") fatalError("init(coder:) has not been implemented")
} }
@objc private func loadBelowPressed() { override func didMoveToSuperview() {
fillGap?(.below) super.didMoveToSuperview()
update()
} }
@objc private func loadAbovePressed() { func update() {
fillGap?(.above) 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 collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var contentOffsetObservation: NSKeyValueObservation?
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline self.timeline = timeline
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -56,8 +58,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if item.hideSeparators { if item.hideSeparators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} } else {
if case .status(_, _) = item {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
} }
@ -87,34 +88,43 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
#endif #endif
#if DEBUG #if DEBUG
// navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), 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() var snapshot = self.dataSource.snapshot()
// let statuses = snapshot.itemIdentifiers(inSection: .statuses) let statuses = snapshot.itemIdentifiers(inSection: .statuses)
// if statuses.count > 20 { if statuses.count > 20 {
// let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10)) let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
// if !toRemove.isEmpty { if !toRemove.isEmpty {
// print("REMOVING MIDDLE \(toRemove.count) STATUSES") print("REMOVING MIDDLE \(toRemove.count) STATUSES")
// snapshot.insertItems([.gap], beforeItem: toRemove.first!) snapshot.insertItems([.gap], beforeItem: toRemove.first!)
// snapshot.deleteItems(toRemove) snapshot.deleteItems(toRemove)
// self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
// } }
// } }
// })) }))
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in let rewind = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
let statuses = snapshot.itemIdentifiers(inSection: .statuses) let statuses = snapshot.itemIdentifiers(inSection: .statuses)
if statuses.count > 20 { if statuses.count > 20 {
let toRemove = Array(statuses.dropLast(20)) let toRemove = Array(statuses.dropLast(20))
snapshot.deleteItems(toRemove) snapshot.deleteItems(toRemove)
self.dataSource.apply(snapshot) 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 #else
#error("remove me") #error("remove me")
#endif #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 // 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 let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1) self.configureStatusCell(cell, id: item.0, state: item.1)
} }
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
cell.fillGap = { [unowned self] direction in cell.showsIndicator = false
Task {
await self.controller.fillGap(in: direction)
}
}
} }
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
guard case .public(let local) = timeline else { guard case .public(let local) = timeline else {
@ -276,7 +282,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
var config = ToastConfiguration(title: "Jump to present") var config = ToastConfiguration(title: "Jump to present")
config.edge = .top config.edge = .top
config.systemImageName = "arrow.up" config.systemImageName = "arrow.up"
config.dismissAutomaticallyAfter = 2 config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in config.action = { [unowned self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
@ -359,7 +365,7 @@ extension TimelineViewController {
var isSelectable: Bool { var isSelectable: Bool {
switch self { switch self {
case .publicTimelineDescription, .status(id: _, state: _): case .publicTimelineDescription, .gap, .status(id: _, state: _):
return true return true
default: default:
return false return false
@ -519,6 +525,8 @@ extension TimelineViewController {
if indexOfFirstTimelineItemExistingBelowGap != nil { if indexOfFirstTimelineItemExistingBelowGap != nil {
snapshot.deleteItems([.gap]) snapshot.deleteItems([.gap])
} }
await apply(snapshot, animatingDifferences: !addedItems)
case .below: case .below:
let beforeGap = statusItems[..<gapIndex].suffix(20) let beforeGap = statusItems[..<gapIndex].suffix(20)
@ -551,6 +559,34 @@ extension TimelineViewController {
if indexOfLastTimelineItemExistingAboveGap != nil { if indexOfLastTimelineItemExistingAboveGap != nil {
snapshot.deleteItems([.gap]) 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 // 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 config.dismissAutomaticallyAfter = 2
showToast(configuration: config, animated: true) showToast(configuration: config, animated: true)
} }
await apply(snapshot, animatingDifferences: true)
} }
enum Error: TimelineLikeCollectionViewError { enum Error: TimelineLikeCollectionViewError {
@ -601,7 +635,14 @@ extension TimelineViewController: UICollectionViewDelegate {
let status = mastodonController.persistentContainer.status(for: id)! 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 // 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()) 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") fatalError("unreachable")
} }
} }

View File

@ -206,7 +206,7 @@ class TimelineLikeController<Item> {
case .loadingOlder(let token, let hasAddedLoadingIndicator): case .loadingOlder(let token, let hasAddedLoadingIndicator):
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))" return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
case .loadingGap(let token, let direction): 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) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .secondarySystemBackground backgroundColor = .systemGroupedBackground
let label = UILabel() let label = UILabel()
label.text = "Infinite scrolling is off. Do you want to keep going?" label.text = "Infinite scrolling is off. Do you want to keep going?"