diff --git a/Tusker.xcodeproj/xcshareddata/xcschemes/Tusker.xcscheme b/Tusker.xcodeproj/xcshareddata/xcschemes/Tusker.xcscheme
index eea12641..50066d9e 100644
--- a/Tusker.xcodeproj/xcshareddata/xcschemes/Tusker.xcscheme
+++ b/Tusker.xcodeproj/xcshareddata/xcschemes/Tusker.xcscheme
@@ -99,6 +99,11 @@
value = "1"
isEnabled = "NO">
+
+
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()
}
}
diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift
index 2df02199..5de6b920 100644
--- a/Tusker/Screens/Timeline/TimelineViewController.swift
+++ b/Tusker/Screens/Timeline/TimelineViewController.swift
@@ -22,6 +22,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource!
+ 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 { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1)
}
- let gapCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, _ in
- cell.fillGap = { [unowned self] direction in
- Task {
- await self.controller.fillGap(in: direction)
- }
- }
+ let gapCell = UICollectionView.CellRegistration { cell, indexPath, _ in
+ cell.showsIndicator = false
}
let timelineDescriptionCell = UICollectionView.CellRegistration { [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[.. {
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))"
}
}
diff --git a/Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift b/Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift
index 5caf121e..0c5514bd 100644
--- a/Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift
+++ b/Tusker/Views/Confirm Load More Cell/ConfirmLoadMoreCollectionViewCell.swift
@@ -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?"