Compare commits

...

13 Commits

18 changed files with 388 additions and 61 deletions

View File

@ -1,5 +1,17 @@
# Changelog
## 2024.3 (128)
Bugfixes:
- Fix selecting poll option playing too much haptic feedback
- Fix crash when displaying HTML in certain posts
- Fix gifv playback pausing audio from other apps
- Fix gifv playback not resuming after returning from background
- Fix attachment badges not appearing on gifvs
- iPadOS: Fix poll options not having pointer hover effects
- iPadOS: Fix haptic feedback not working on new Magic Keyboard
- iPadOS: Fix scrubbing video with pointer not letting you click to select position
- iPadOS: Fix multi-column navigation not animating when replacing multiple columns
## 2024.3 (127)
Bugfixes:
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen

View File

@ -152,14 +152,21 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
extension GalleryViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
#if os(visionOS)
return nil
#else
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
return GalleryPresentationAnimationController(sourceView: sourceView)
} else {
return nil
}
#endif
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
#if os(visionOS)
return nil
#else
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
let translation: CGPoint?
let velocity: CGPoint?
@ -175,5 +182,6 @@ extension GalleryViewController: UIViewControllerTransitioningDelegate {
} else {
return nil
}
#endif
}
}

View File

@ -361,6 +361,7 @@
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
@ -803,6 +804,7 @@
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionCoordinator.swift; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
@ -1607,6 +1609,7 @@
D691296D2BA75ACF005C58ED /* PrivacyInfo.xcprivacy */,
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */,
D6D79F582A13293200AB2315 /* BackgroundManager.swift */,
D69261262BB3BA610023152C /* Box.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */,
@ -2147,6 +2150,7 @@
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */,
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
@ -3252,7 +3256,7 @@
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
requirement = {
kind = exactVersion;
version = 0.2.4;
version = 0.2.5;
};
};
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {

View File

@ -0,0 +1,92 @@
//
// AudioSessionCoordinator.swift
// Tusker
//
// Created by Shadowfacts on 7/8/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import AVFoundation
final class AudioSessionCoordinator {
static let shared = AudioSessionCoordinator()
private init() {}
private let queue = DispatchQueue(label: "AudioSessionCoordinator", qos: .userInitiated)
private var videoCount = 0
private var gifvCount = 0
func beginPlayback(mode: Mode, completionHandler: (() -> Void)? = nil) -> Token {
let token = Token(mode: mode)
queue.async {
switch mode {
case .video:
self.videoCount += 1
case .gifv:
self.gifvCount += 1
}
self.update(completionHandler: completionHandler)
}
return token
}
func endPlayback(token: Token, completionHandler: (() -> Void)? = nil) {
// mark the token as consumed, so when it's deinited we don't try to end again
token.consumed = true
// the enqueued block can't retain token, since it may be being dealloc'd right now
let mode = token.mode
queue.async {
switch mode {
case .video:
self.videoCount -= 1
case .gifv:
self.gifvCount -= 1
}
self.update(completionHandler: completionHandler)
}
}
private func update(completionHandler: (() -> Void)?) {
let currentCategory = AVAudioSession.sharedInstance().category
if videoCount > 0 {
try? AVAudioSession.sharedInstance().setCategory(.playback)
try? AVAudioSession.sharedInstance().setActive(true)
} else if gifvCount > 0 {
// if we're transitioning from video to gifv, fully deactivate first
// in order to let other (music) apps resume, then activate with the
// ambient category to "mix" with others
if currentCategory == .playback {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
// only gifv modes requested, allow mixing with others
try? AVAudioSession.sharedInstance().setCategory(.ambient)
try? AVAudioSession.sharedInstance().setActive(true)
} else {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
completionHandler?()
}
final class Token {
let mode: Mode
fileprivate var consumed = false
init(mode: Mode) {
self.mode = mode
}
deinit {
if !consumed {
AudioSessionCoordinator.shared.endPlayback(token: self)
}
}
}
enum Mode {
case video
case gifv
}
}

View File

@ -204,14 +204,19 @@ class FastAccountSwitcherViewController: UIViewController {
@objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
switch recognizer.state {
case .began:
show()
#if !os(visionOS)
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
if #available(iOS 17.5, *) {
UIImpactFeedbackGenerator(style: .heavy, view: view).impactOccurred(at: CGPoint(x: view.bounds.midX, y: view.bounds.midY))
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator(view: view)
} else {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
}
selectionChangedFeedbackGenerator?.prepare()
#endif
show()
case .changed:
let location = recognizer.location(in: view)
@ -260,7 +265,11 @@ class FastAccountSwitcherViewController: UIViewController {
#if !os(visionOS)
if hapticFeedback {
selectionChangedFeedbackGenerator?.selectionChanged()
if #available(iOS 17.5, *) {
selectionChangedFeedbackGenerator?.selectionChanged(at: location)
} else {
selectionChangedFeedbackGenerator?.selectionChanged()
}
selectionChangedFeedbackGenerator?.prepare()
}
#endif

View File

@ -270,8 +270,13 @@ private class VideoScrubbingControl: UIControl {
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
touchStartLocation = touch.location(in: self)
scrubbingStartFraction = fractionComplete
if touch.type == .pencil || touch.type == .indirectPointer {
touchStartLocation = .zero
scrubbingStartFraction = 0
} else {
touchStartLocation = touch.location(in: self)
scrubbingStartFraction = fractionComplete
}
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
animator!.addAnimations {
@ -282,17 +287,28 @@ private class VideoScrubbingControl: UIControl {
sendActions(for: .editingDidBegin)
#if !os(visionOS)
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
if #available(iOS 17.5, *) {
feedbackGenerator = UIImpactFeedbackGenerator(style: .light, view: self)
} else {
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
}
feedbackGenerator!.prepare()
#endif
updateScrubbing(for: touch)
return true
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
updateScrubbing(for: touch)
return true
}
private func updateScrubbing(for touch: UITouch) {
guard let touchStartLocation,
let scrubbingStartFraction else {
return false
return
}
let location = touch.location(in: self)
let translation = CGPoint(x: location.x - touchStartLocation.x, y: location.y - touchStartLocation.y)
@ -301,7 +317,11 @@ private class VideoScrubbingControl: UIControl {
let newFractionComplete = max(0, min(1, unclampedFractionComplete))
#if !os(visionOS)
if newFractionComplete != fractionComplete && (newFractionComplete == 0 || newFractionComplete == 1) {
feedbackGenerator!.impactOccurred(intensity: 0.5)
if #available(iOS 17.5, *) {
feedbackGenerator!.impactOccurred(intensity: 0.5, at: location)
} else {
feedbackGenerator!.impactOccurred(intensity: 0.5)
}
}
#endif
fractionComplete = newFractionComplete
@ -318,8 +338,6 @@ private class VideoScrubbingControl: UIControl {
transform = CGAffineTransform(scaleX: 1 + stretchAmount / bounds.width, y: 1 + 0.5 * (1 - stretchFactor))
.translatedBy(x: sign(unclampedFractionComplete) * stretchAmount / 2, y: 0)
}
return true
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {

View File

@ -29,6 +29,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
private var rateObservation: NSKeyValueObservation?
private var isFirstAppearance = true
private var hideControlsWorkItem: DispatchWorkItem?
private var audioSessionToken: AudioSessionCoordinator.Token?
init(url: URL, caption: String?) {
self.url = url
@ -172,10 +173,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
let wasFirstAppearance = isFirstAppearance
isFirstAppearance = false
DispatchQueue.global(qos: .userInitiated).async {
try? AVAudioSession.sharedInstance().setCategory(.playback)
try? AVAudioSession.sharedInstance().setActive(true)
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
if wasFirstAppearance {
DispatchQueue.main.async {
self.player.play()
@ -187,8 +185,8 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
func galleryContentWillDisappear() {
player.pause()
DispatchQueue.global(qos: .userInitiated).async {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
}

View File

@ -150,9 +150,16 @@ class CustomAlertActionsView: UIControl {
private var separatorSizeConstraints: [NSLayoutConstraint] = []
#if !os(visionOS)
private let generator = UISelectionFeedbackGenerator()
private lazy var generator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif
private var currentSelectedActionIndex: Int?
private var showPressedMenuWorkItem: DispatchWorkItem?
init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) {
self.dismiss = dismiss
@ -313,13 +320,42 @@ class CustomAlertActionsView: UIControl {
actionButtons[currentSelectedActionIndex].backgroundColor = nil
}
#if !os(visionOS)
generator.selectionChanged()
if #available(iOS 17.5, *) {
let view = selectedButton!.element
let location = convert(CGPoint(x: view.bounds.midX, y: view.bounds.midY), from: view)
generator.selectionChanged(at: location)
} else {
generator.selectionChanged()
}
#endif
if let showPressedMenuWorkItem {
showPressedMenuWorkItem.cancel()
self.showPressedMenuWorkItem = nil
}
}
currentSelectedActionIndex = selectedButton?.offset
selectedButton?.element.backgroundColor = .secondarySystemFill
if let currentSelectedActionIndex,
case .menu(_) = reorderedActions[currentSelectedActionIndex].style,
case let button = actionButtons[currentSelectedActionIndex],
let interaction = button.contextMenuInteraction,
showPressedMenuWorkItem == nil {
showPressedMenuWorkItem = DispatchWorkItem {
if #available(iOS 17.4, *) {
button.performPrimaryAction()
} else {
let selector = NSSelectorFromString(["Location:", "At", "Menu", "present", "_"].reversed().joined())
if interaction.responds(to: selector) {
interaction.perform(selector, with: recognizer.location(in: button))
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: showPressedMenuWorkItem!)
}
#if !os(visionOS)
generator.prepare()
#endif

View File

@ -17,7 +17,6 @@ protocol MultiColumnNavigationCustomTargetProviding {
class MultiColumnNavigationController: UIViewController {
private var isManuallyUpdating = false
private var _viewControllers: [UIViewController] = []
var viewControllers: [UIViewController] {
get {
@ -25,8 +24,7 @@ class MultiColumnNavigationController: UIViewController {
}
set {
_viewControllers = newValue
if isViewLoaded,
!isManuallyUpdating {
if isViewLoaded {
updateViews()
scrollToEnd(animated: false)
}
@ -143,7 +141,14 @@ class MultiColumnNavigationController: UIViewController {
}
private func scrollColumnToEnd(columnIndex: Int, animated: Bool) {
// Laying out may change the content offset if we have fewer columns than before,
// but we want to keep the original offset so that we can animate smoothly to the final one.
let origContentOffset = scrollView.contentOffset
scrollView.layoutIfNeeded()
if animated {
scrollView.contentOffset = origContentOffset
}
let column = stackView.arrangedSubviews[columnIndex]
let columnFrame = column.convert(column.bounds, to: scrollView)
let offset: CGFloat

View File

@ -380,6 +380,8 @@ class AttachmentView: GIFImageView {
self.badgeContainer = stack
stack.axis = .horizontal
stack.spacing = 2
// badges should appear on top of any subsequently added views (e.g., gifv)
stack.layer.zPosition = 100
stack.translatesAutoresizingMaskIntoConstraints = false
let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .bold))

View File

@ -17,6 +17,7 @@ class GifvController {
let player: AVPlayer
private var isGrayscale = false
private var audioSessionToken: AudioSessionCoordinator.Token?
let presentationSizeSubject = PassthroughSubject<CGSize, Never>()
private var presentationSizeObservation: NSKeyValueObservation?
@ -29,7 +30,9 @@ class GifvController {
self.isGrayscale = Preferences.shared.grayscaleImages
player.isMuted = true
#if !os(visionOS)
#if os(visionOS)
player.preventsAutomaticBackgroundingDuringVideoPlayback = false
#else
player.preventsDisplaySleepDuringVideoPlayback = false
#endif
@ -41,12 +44,19 @@ class GifvController {
func play() {
if player.rate == 0 {
player.play()
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .gifv) {
DispatchQueue.main.async {
self.player.play()
}
}
}
}
func pause() {
player.pause()
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
}
private func updatePresentationSizeObservation() {

View File

@ -22,6 +22,7 @@ class GifvPlayerView: UIView {
let controller: GifvController
private var presentationSizeCancellable: AnyCancellable?
private var wasPlayingWhenSceneBackgrounded = false
override var intrinsicContentSize: CGSize {
controller.item.presentationSize
@ -45,4 +46,30 @@ class GifvPlayerView: UIView {
fatalError("init(coder:) has not been implemented")
}
override func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)
if let oldWindow = window,
let oldScene = oldWindow.windowScene {
NotificationCenter.default.removeObserver(self, name: UIScene.didEnterBackgroundNotification, object: oldScene)
NotificationCenter.default.removeObserver(self, name: UIScene.willEnterForegroundNotification, object: oldScene)
}
if let newWindow,
let newScene = newWindow.windowScene {
NotificationCenter.default.addObserver(self, selector: #selector(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: newScene)
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: newScene)
}
}
@objc private func sceneDidEnterBackground() {
wasPlayingWhenSceneBackgrounded = controller.player.rate > 0
}
@objc private func sceneWillEnterForeground() {
if wasPlayingWhenSceneBackgrounded {
controller.play()
}
}
}

View File

@ -14,6 +14,7 @@ class PollOptionView: UIView {
private static let minHeight: CGFloat = 35
private static let cornerRadius = 0.1 * minHeight
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
private static let hoveredBackgroundColor = UIColor(white: 0.35, alpha: 0.25)
private(set) var label: EmojiLabel!
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure {
@ -33,6 +34,12 @@ class PollOptionView: UIView {
private var labelLeadingToSelfConstraint: NSLayoutConstraint!
private var fillViewWidthConstraint: NSLayoutConstraint?
var hovered: Bool = false {
didSet {
backgroundColor = hovered ? PollOptionView.hoveredBackgroundColor : PollOptionView.unselectedBackgroundColor
}
}
init() {
super.init(frame: .zero)

View File

@ -24,12 +24,19 @@ class PollOptionsView: UIControl {
private var poll: Poll!
private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int?
private var currentHoveredOptionIndex: Int?
private let animationDuration: TimeInterval = 0.1
private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
static let animationDuration: TimeInterval = 0.1
static let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
#if !os(visionOS)
private let generator = UISelectionFeedbackGenerator()
private lazy var generator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif
override var isEnabled: Bool {
@ -59,6 +66,8 @@ class PollOptionsView: UIControl {
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
}
required init?(coder: NSCoder) {
@ -121,6 +130,20 @@ class PollOptionsView: UIControl {
}
}
private func optionView(at point: CGPoint) -> (PollOptionView, Int)? {
for (index, view) in options.enumerated() {
// don't use view.frame because it changes when a transform is applied
var frame = CGRect(x: 0, y: view.center.y - view.bounds.height / 2, width: view.bounds.width, height: view.bounds.height)
if index != options.count - 1 {
frame = frame.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: -stack.spacing, right: 0))
}
if frame.contains(point) {
return (view, index)
}
}
return nil
}
// MARK: - UIControl
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
@ -132,13 +155,21 @@ class PollOptionsView: UIControl {
if view.point(inside: touch.location(in: view), with: event) {
currentSelectedOptionIndex = index
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
view.transform = self.scaledTransform
if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
view.transform = Self.scaledTransform
view.hovered = true
}
animator.startAnimation()
#if !os(visionOS)
generator.selectionChanged()
if #available(iOS 17.5, *) {
generator.selectionChanged(at: view.center)
} else {
generator.selectionChanged()
}
generator.prepare()
#endif
@ -151,30 +182,31 @@ class PollOptionsView: UIControl {
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let location = touch.location(in: self)
var newIndex: Int? = nil
for (index, view) in options.enumerated() {
var frame = view.frame
if index != options.count - 1 {
frame = frame.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: -stack.spacing, right: 0))
}
if frame.contains(location) {
newIndex = index
break
}
}
let newIndexAndOption = optionView(at: location)
let newIndex = newIndexAndOption?.1
let option = newIndexAndOption?.0
if newIndex != currentSelectedOptionIndex {
currentSelectedOptionIndex = newIndex
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) {
if animator.isRunning {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
for (index, view) in self.options.enumerated() {
view.transform = index == newIndex ? self.scaledTransform : .identity
view.transform = index == newIndex ? Self.scaledTransform : .identity
view.hovered = index == newIndex
}
}
animator.startAnimation()
#if !os(visionOS)
if newIndex != nil {
generator.selectionChanged()
if let option {
if #available(iOS 17.5, *) {
generator.selectionChanged(at: option.center)
} else {
generator.selectionChanged()
}
generator.prepare()
}
#endif
@ -189,14 +221,15 @@ class PollOptionsView: UIControl {
func selectOption() {
guard let index = currentSelectedOptionIndex else { return }
let option = options[index]
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
option.transform = .identity
option.hovered = false
self.selectOption(option)
}
animator.startAnimation()
}
if animator.isRunning {
if animator?.isRunning == true {
animator.addCompletion { (_) in
selectOption()
}
@ -207,12 +240,52 @@ class PollOptionsView: UIControl {
override func cancelTracking(with event: UIEvent?) {
super.cancelTracking(with: event)
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) {
if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
for view in self.options {
view.transform = .identity
view.hovered = false
}
}
animator.startAnimation()
}
@objc private func hoverRecognized(_ recognizer: UIHoverGestureRecognizer) {
guard let (option, index) = optionView(at: recognizer.location(in: self)) else {
return
}
switch recognizer.state {
case .began, .changed:
if index != currentHoveredOptionIndex {
let oldIndex = currentHoveredOptionIndex
currentHoveredOptionIndex = index
if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
option.hovered = true
if let oldIndex {
self.options[oldIndex].hovered = false
}
}
animator.startAnimation()
}
case .ended, .cancelled:
if let currentHoveredOptionIndex {
self.currentHoveredOptionIndex = nil
if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
self.options[currentHoveredOptionIndex].hovered = false
}
animator.startAnimation()
}
default:
break
}
}
}

View File

@ -65,6 +65,7 @@ class StatusPollView: UIView, StatusContentView {
addSubview(infoLabel)
voteButton = UIButton(configuration: .plain())
voteButton.isPointerInteractionEnabled = true
voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)

View File

@ -24,7 +24,13 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
private var changeSelectionPanRecognizer: UIGestureRecognizer!
private var selectedOptionAtStartOfPan: Value?
#if !os(visionOS)
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
private lazy var selectionChangedFeedbackGenerator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif
override var intrinsicContentSize: CGSize {
@ -111,13 +117,19 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
func setSelectedOption(_ value: Value, animated: Bool) {
guard selectedOption != value,
options.contains(where: { $0.value == value }) else {
let index = options.firstIndex(where: { $0.value == value }) else {
return
}
#if !os(visionOS)
if selectedOption != nil {
selectionChangedFeedbackGenerator.selectionChanged()
if #available(iOS 17.5, *) {
let optionView = optionsStack.arrangedSubviews[index]
let location = convert(CGPoint(x: optionView.bounds.midX, y: optionView.bounds.midY), from: optionView)
selectionChangedFeedbackGenerator.selectionChanged(at: location)
} else {
selectionChangedFeedbackGenerator.selectionChanged()
}
}
#endif
@ -158,15 +170,19 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
// MARK: Interaction
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer === self.panGestureRecognizer else {
return true
}
let beganOnSelectedOption: Bool
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
optionsStack.arrangedSubviews[selectedIndex].frame.contains(self.panGestureRecognizer.location(in: optionsStack)) {
optionsStack.arrangedSubviews[selectedIndex].frame.contains(gestureRecognizer.location(in: optionsStack)) {
beganOnSelectedOption = true
} else {
beganOnSelectedOption = false
}
// only begin changing selection if the gesutre started on the currently selected item
// only begin changing selection if the gesture started on the currently selected item
// otherwise, let the scroll view handle things
if gestureRecognizer == self.changeSelectionPanRecognizer {
return beganOnSelectedOption
@ -223,7 +239,12 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
}
animator.startAnimation()
#if !os(visionOS)
selectionChangedFeedbackGenerator.selectionChanged()
if #available(iOS 17.5, *) {
let locationInSelf = convert(location, from: optionsStack)
selectionChangedFeedbackGenerator.selectionChanged(at: locationInSelf)
} else {
selectionChangedFeedbackGenerator.selectionChanged()
}
#endif
return true
} else {

View File

@ -8,7 +8,7 @@
import Foundation
class WeakHolder<T: AnyObject> {
final class WeakHolder<T: AnyObject> {
weak var object: T?
init(_ object: T?) {
@ -16,7 +16,7 @@ class WeakHolder<T: AnyObject> {
}
}
struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollection {
struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollection, RandomAccessCollection, BidirectionalCollection {
private var array: [WeakHolder<Element>]
var startIndex: Int { array.startIndex }
@ -47,6 +47,10 @@ struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollect
return array.index(after: i)
}
func index(before i: Int) -> Int {
return array.index(before: i)
}
mutating func replaceSubrange<C>(_ subrange: Range<Int>, with newElements: C) where C : Collection, Self.Element == C.Element {
array.replaceSubrange(subrange, with: newElements.map { WeakHolder($0) })
}

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.3
CURRENT_PROJECT_VERSION = 127
CURRENT_PROJECT_VERSION = 128
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev