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?"