Compare commits

...

6 Commits

Author SHA1 Message Date
Shadowfacts cca2a03b2f When routing the SplitNav responder chain through the root VC, go as deep into it as possible
Makes keyboard shortcuts from, e.g., TimelineVC accessible when the root is TimelinesPageVC

See #302
2023-01-20 11:34:44 -05:00
Shadowfacts 1a64bfcef8 Disallow keyboard focus in sidebar
Makes keyboard shortcuts from the split VC's primary content available

See #302
2023-01-20 11:33:28 -05:00
Shadowfacts 907810d98a Make link preview cards larger 2023-01-20 11:22:28 -05:00
Shadowfacts 23a4999196 Complete asynchronous swipe actions immediately
Fixes crash when the user things the action has failed and taps it
again, which results in an invalid completion handler later being called
2023-01-20 10:53:30 -05:00
Shadowfacts 3e0feba273 Fix collapse button disappearing when navigating away 2023-01-20 10:51:56 -05:00
Shadowfacts 468a559127 Fix crash when TimelinePosition's center status ID isn't in the list of IDs 2023-01-19 21:46:57 -05:00
10 changed files with 125 additions and 41 deletions

View File

@ -99,9 +99,9 @@ private func createFavoriteAction(status: StatusMO, container: StatusSwipeAction
} }
let title = status.favourited ? "Unfavorite" : "Favorite" let title = status.favourited ? "Unfavorite" : "Favorite"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
completion(true)
Task { @MainActor in Task { @MainActor in
await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite() await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
completion(true)
} }
} }
action.image = UIImage(systemName: "star.fill") action.image = UIImage(systemName: "star.fill")
@ -116,9 +116,9 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
} }
let title = status.reblogged ? "Unreblog" : "Reblog" let title = status.reblogged ? "Unreblog" : "Reblog"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
completion(true)
Task { @MainActor in Task { @MainActor in
await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog() await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
completion(true)
} }
} }
action.image = UIImage(systemName: "repeat") action.image = UIImage(systemName: "repeat")
@ -145,6 +145,7 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
let bookmarked = status.bookmarked ?? false let bookmarked = status.bookmarked ?? false
let title = bookmarked ? "Unbookmark" : "Bookmark" let title = bookmarked ? "Unbookmark" : "Bookmark"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
completion(true)
Task { @MainActor in Task { @MainActor in
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id) let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
do { do {
@ -156,7 +157,6 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
toastable.showToast(configuration: config, animated: true) toastable.showToast(configuration: config, animated: true)
} }
} }
completion(true)
} }
} }
action.image = UIImage(systemName: "bookmark.fill") action.image = UIImage(systemName: "bookmark.fill")

View File

@ -89,6 +89,8 @@ class MainSidebarViewController: UIViewController {
collectionView.delegate = self collectionView.delegate = self
collectionView.dragDelegate = self collectionView.dragDelegate = self
collectionView.isSpringLoaded = true collectionView.isSpringLoaded = true
// TODO: allow focusing sidebar once there's a workaround for keyboard shortcuts from main split content not being accessible when not in the responder chain
collectionView.allowsFocus = false
view.addSubview(collectionView) view.addSubview(collectionView)
dataSource = createDataSource() dataSource = createDataSource()

View File

@ -432,8 +432,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
// update the timeline position in case some statuses couldn't be loaded // update the timeline position in case some statuses couldn't be loaded
if let center = position.centerStatusID { if let center = position.centerStatusID,
let nearestLoadedStatusToCenter = position.statusIDs[position.statusIDs.firstIndex(of: center)!...].first(where: { id in let centerIndex = position.statusIDs.firstIndex(of: center) {
let nearestLoadedStatusToCenter = position.statusIDs[centerIndex...].first(where: { id in
// was already loaded or was just now loaded // was already loaded or was just now loaded
!unloaded.contains(id) || statuses.contains(where: { $0.id == id }) !unloaded.contains(id) || statuses.contains(where: { $0.id == id })
}) })

View File

@ -198,3 +198,9 @@ extension SegmentedPageViewController: StatusBarTappableViewController {
return .continue return .continue
} }
} }
extension SegmentedPageViewController: NestedResponderProvider {
var innerResponder: UIResponder? {
currentViewController
}
}

View File

@ -283,7 +283,11 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
// ordinarily, the next responder in the chain would be the SplitNavigationController's view // ordinarily, the next responder in the chain would be the SplitNavigationController's view
// but that would bypass the VC in the root nav, so we reroute the repsonder chain to include it // but that would bypass the VC in the root nav, so we reroute the repsonder chain to include it
// first seems to be nil when using the view debugger for some reason, so in that case, defer to super // first seems to be nil when using the view debugger for some reason, so in that case, defer to super
owner.viewControllers.first?.view ?? super.next if let root = owner.viewControllers.first {
return root.innermostResponder() ?? super.next
} else {
return super.next
}
} }
private func configureSecondarySplitCloseButton(for viewController: UIViewController) { private func configureSecondarySplitCloseButton(for viewController: UIViewController) {
@ -300,3 +304,17 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
} }
} }
protocol NestedResponderProvider {
var innerResponder: UIResponder? { get }
}
extension UIResponder {
func innermostResponder() -> UIResponder? {
if let nestedProvider = self as? NestedResponderProvider {
return nestedProvider.innerResponder?.innermostResponder() ?? self
} else {
return self
}
}
}

View File

@ -361,23 +361,31 @@ class BaseStatusTableViewCell: UITableViewCell {
let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")! let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")!
if animated, let buttonImageView = collapseButton.imageView { if let buttonImageView = collapseButton.imageView {
// we need to use a keyframe animation for this, because we want to control the direction the chevron rotates
// when rotating ±π, UIKit will always rotate in the same direction
// using a keyframe to set an intermediate point in the animation allows us to force a specific direction
UIView.animateKeyframes(withDuration: 0.2, delay: 0, options: .calculationModeLinear, animations: {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
buttonImageView.transform = CGAffineTransform(rotationAngle: collapsed ? .pi / 2 : -.pi / 2)
}
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
buttonImageView.transform = CGAffineTransform(rotationAngle: .pi)
}
}, completion: { (finished) in
buttonImageView.transform = .identity
self.collapseButton.setImage(buttonImage, for: .normal)
})
} else {
collapseButton.setImage(buttonImage, for: .normal) collapseButton.setImage(buttonImage, for: .normal)
if animated {
buttonImageView.layer.opacity = 0
// this whole hack is necessary because when just rotating buttonImageView, it moves to the left of the button and then animates back to the center
let imageView = UIImageView(image: buttonImageView.image)
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalTo: buttonImageView.widthAnchor),
imageView.heightAnchor.constraint(equalTo: buttonImageView.heightAnchor),
imageView.centerXAnchor.constraint(equalTo: collapseButton.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: collapseButton.centerYAnchor),
])
imageView.tintColor = .white
UIView.animate(withDuration: 0.3, delay: 0) {
imageView.transform = CGAffineTransform(rotationAngle: .pi)
} completion: { _ in
imageView.removeFromSuperview()
buttonImageView.layer.opacity = 1
}
}
} }
if collapsed { if collapsed {

View File

@ -81,10 +81,14 @@
<constraints> <constraints>
<constraint firstAttribute="height" constant="30" id="icD-3q-uJ6"/> <constraint firstAttribute="height" constant="30" id="icD-3q-uJ6"/>
</constraints> </constraints>
<color key="tintColor" systemColor="systemBackgroundColor"/> <color key="tintColor" systemColor="tintColor"/>
<state key="normal" image="chevron.down" catalog="system"> <state key="normal" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/> <preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state> </state>
<buttonConfiguration key="configuration" style="filled" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfigurationForImage" scale="large"/>
<color key="baseForegroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</buttonConfiguration>
<connections> <connections>
<action selector="collapseButtonPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="00b-nM-U5g"/> <action selector="collapseButtonPressed" destination="IDI-ur-8pa" eventType="touchUpInside" id="00b-nM-U5g"/>
</connections> </connections>
@ -98,9 +102,9 @@
</textView> </textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="131" width="361" height="0.0"/> <rect key="frame" x="0.0" y="131" width="361" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints> <constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="Tdo-Hv-ITE"/> <constraint firstAttribute="height" priority="999" constant="90" id="Tdo-Hv-ITE"/>
</constraints> </constraints>
</view> </view>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">

View File

@ -25,8 +25,10 @@ class StatusCardView: UIView {
private var imageRequest: ImageCache.Request? private var imageRequest: ImageCache.Request?
private var isGrayscale = false private var isGrayscale = false
private var hStack: UIStackView!
private var titleLabel: UILabel! private var titleLabel: UILabel!
private var descriptionLabel: UILabel! private var descriptionLabel: UILabel!
private var domainLabel: UILabel!
private var imageView: UIImageView! private var imageView: UIImageView!
private var placeholderImageView: UIImageView! private var placeholderImageView: UIImageView!
@ -41,11 +43,13 @@ class StatusCardView: UIView {
} }
private func commonInit() { private func commonInit() {
self.clipsToBounds = true // self.clipsToBounds = true
self.layer.cornerRadius = 6.5 // self.layer.borderWidth = 0.5
self.layer.borderWidth = 1 // self.layer.borderColor = UIColor.lightGray.cgColor
self.layer.borderColor = UIColor.lightGray.cgColor self.layer.shadowColor = UIColor.black.cgColor
self.backgroundColor = inactiveBackgroundColor self.layer.shadowRadius = 5
self.layer.shadowOpacity = 0.2
self.layer.shadowOffset = .zero
self.addInteraction(UIContextMenuInteraction(delegate: self)) self.addInteraction(UIContextMenuInteraction(delegate: self))
@ -60,9 +64,16 @@ class StatusCardView: UIView {
descriptionLabel.numberOfLines = 2 descriptionLabel.numberOfLines = 2
descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical) descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
domainLabel = UILabel()
domainLabel.font = .preferredFont(forTextStyle: .caption2)
domainLabel.adjustsFontForContentSizeCategory = true
domainLabel.numberOfLines = 1
domainLabel.textColor = .tintColor
let vStack = UIStackView(arrangedSubviews: [ let vStack = UIStackView(arrangedSubviews: [
titleLabel, titleLabel,
descriptionLabel descriptionLabel,
domainLabel,
]) ])
vStack.axis = .vertical vStack.axis = .vertical
vStack.alignment = .leading vStack.alignment = .leading
@ -73,15 +84,23 @@ class StatusCardView: UIView {
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
let hStack = UIStackView(arrangedSubviews: [ let spacer = UIView()
spacer.backgroundColor = .clear
hStack = UIStackView(arrangedSubviews: [
imageView, imageView,
vStack vStack,
spacer,
]) ])
hStack.translatesAutoresizingMaskIntoConstraints = false hStack.translatesAutoresizingMaskIntoConstraints = false
hStack.axis = .horizontal hStack.axis = .horizontal
hStack.alignment = .center hStack.alignment = .center
hStack.distribution = .fill hStack.distribution = .fill
hStack.spacing = 4 hStack.spacing = 4
hStack.clipsToBounds = true
hStack.layer.borderWidth = 0.5
hStack.layer.borderColor = UIColor.lightGray.cgColor
hStack.backgroundColor = inactiveBackgroundColor
addSubview(hStack) addSubview(hStack)
@ -98,8 +117,10 @@ class StatusCardView: UIView {
vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8), vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8),
spacer.widthAnchor.constraint(equalToConstant: 4),
hStack.leadingAnchor.constraint(equalTo: leadingAnchor), hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), hStack.trailingAnchor.constraint(equalTo: trailingAnchor),
hStack.topAnchor.constraint(equalTo: topAnchor), hStack.topAnchor.constraint(equalTo: topAnchor),
hStack.bottomAnchor.constraint(equalTo: bottomAnchor), hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
@ -112,6 +133,11 @@ class StatusCardView: UIView {
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
override func layoutSubviews() {
super.layoutSubviews()
hStack.layer.cornerRadius = 0.1 * bounds.height
}
func updateUI(status: StatusMO) { func updateUI(status: StatusMO) {
guard status.id != statusID else { guard status.id != statusID else {
return return
@ -135,6 +161,13 @@ class StatusCardView: UIView {
let description = card.description.trimmingCharacters(in: .whitespacesAndNewlines) let description = card.description.trimmingCharacters(in: .whitespacesAndNewlines)
descriptionLabel.text = description descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty descriptionLabel.isHidden = description.isEmpty
if let host = card.url.host {
domainLabel.text = host.serialized
domainLabel.isHidden = false
} else {
domainLabel.isHidden = true
}
} }
@objc private func updateUIForPreferences() { @objc private func updateUIForPreferences() {
@ -201,7 +234,7 @@ class StatusCardView: UIView {
} }
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = activeBackgroundColor hStack.backgroundColor = activeBackgroundColor
setNeedsDisplay() setNeedsDisplay()
} }
@ -209,7 +242,7 @@ class StatusCardView: UIView {
} }
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = inactiveBackgroundColor hStack.backgroundColor = inactiveBackgroundColor
setNeedsDisplay() setNeedsDisplay()
if let card = card, let delegate = navigationDelegate { if let card = card, let delegate = navigationDelegate {
@ -218,7 +251,7 @@ class StatusCardView: UIView {
} }
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = inactiveBackgroundColor hStack.backgroundColor = inactiveBackgroundColor
setNeedsDisplay() setNeedsDisplay()
} }
@ -238,6 +271,12 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
} }
} }
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
let params = UIPreviewParameters()
params.visiblePath = UIBezierPath(roundedRect: hStack.bounds, cornerRadius: hStack.layer.cornerRadius)
return UITargetedPreview(view: hStack, parameters: params)
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController, if let viewController = animator.previewViewController,
let delegate = navigationDelegate { let delegate = navigationDelegate {

View File

@ -20,7 +20,7 @@ class StatusContentContainer: UIView {
let cardView = StatusCardView().configure { let cardView = StatusCardView().configure {
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalToConstant: 65), $0.heightAnchor.constraint(equalToConstant: 90),
]) ])
} }

View File

@ -109,10 +109,16 @@
<constraints> <constraints>
<constraint firstAttribute="height" constant="30" id="z84-XW-gP3"/> <constraint firstAttribute="height" constant="30" id="z84-XW-gP3"/>
</constraints> </constraints>
<color key="tintColor" systemColor="systemBackgroundColor"/> <color key="tintColor" systemColor="tintColor"/>
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
<state key="normal" image="chevron.down" catalog="system"> <state key="normal" image="chevron.down" catalog="system">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/> <preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state> </state>
<buttonConfiguration key="configuration" style="filled" image="chevron.down" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfigurationForImage" scale="large"/>
<color key="baseForegroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</buttonConfiguration>
<connections> <connections>
<action selector="collapseButtonPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="twO-rE-1pQ"/> <action selector="collapseButtonPressed" destination="BR5-ZS-LIo" eventType="touchUpInside" id="twO-rE-1pQ"/>
</connections> </connections>
@ -126,9 +132,9 @@
</textView> </textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="200.5" width="295" height="0.0"/> <rect key="frame" x="0.0" y="200.5" width="295" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints> <constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/> <constraint firstAttribute="height" priority="999" constant="90" id="khY-jm-CPn"/>
</constraints> </constraints>
</view> </view>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">