Compare commits

..

13 Commits

18 changed files with 388 additions and 61 deletions

View File

@ -1,5 +1,17 @@
# Changelog # 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) ## 2024.3 (127)
Bugfixes: Bugfixes:
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen - Fix Remove Suggestion context menu action missing from Suggested Accounts screen

View File

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

View File

@ -361,6 +361,7 @@
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; 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 */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.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 */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
@ -1607,6 +1609,7 @@
D691296D2BA75ACF005C58ED /* PrivacyInfo.xcprivacy */, D691296D2BA75ACF005C58ED /* PrivacyInfo.xcprivacy */,
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */, D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */,
D6D79F582A13293200AB2315 /* BackgroundManager.swift */, D6D79F582A13293200AB2315 /* BackgroundManager.swift */,
D69261262BB3BA610023152C /* Box.swift */, D69261262BB3BA610023152C /* Box.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */, D61F75B6293C119700C0B37F /* Filterer.swift */,
@ -2147,6 +2150,7 @@
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */, D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */,
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */, D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */, D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */, D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
@ -3252,7 +3256,7 @@
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git"; repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
requirement = { requirement = {
kind = exactVersion; kind = exactVersion;
version = 0.2.4; version = 0.2.5;
}; };
}; };
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { 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) { @objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
switch recognizer.state { switch recognizer.state {
case .began: case .began:
show()
#if !os(visionOS) #if !os(visionOS)
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() UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator() selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
}
selectionChangedFeedbackGenerator?.prepare() selectionChangedFeedbackGenerator?.prepare()
#endif #endif
show()
case .changed: case .changed:
let location = recognizer.location(in: view) let location = recognizer.location(in: view)
@ -260,7 +265,11 @@ class FastAccountSwitcherViewController: UIViewController {
#if !os(visionOS) #if !os(visionOS)
if hapticFeedback { if hapticFeedback {
if #available(iOS 17.5, *) {
selectionChangedFeedbackGenerator?.selectionChanged(at: location)
} else {
selectionChangedFeedbackGenerator?.selectionChanged() selectionChangedFeedbackGenerator?.selectionChanged()
}
selectionChangedFeedbackGenerator?.prepare() selectionChangedFeedbackGenerator?.prepare()
} }
#endif #endif

View File

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

View File

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

View File

@ -150,9 +150,16 @@ class CustomAlertActionsView: UIControl {
private var separatorSizeConstraints: [NSLayoutConstraint] = [] private var separatorSizeConstraints: [NSLayoutConstraint] = []
#if !os(visionOS) #if !os(visionOS)
private let generator = UISelectionFeedbackGenerator() private lazy var generator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif #endif
private var currentSelectedActionIndex: Int? private var currentSelectedActionIndex: Int?
private var showPressedMenuWorkItem: DispatchWorkItem?
init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) { init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) {
self.dismiss = dismiss self.dismiss = dismiss
@ -313,13 +320,42 @@ class CustomAlertActionsView: UIControl {
actionButtons[currentSelectedActionIndex].backgroundColor = nil actionButtons[currentSelectedActionIndex].backgroundColor = nil
} }
#if !os(visionOS) #if !os(visionOS)
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() generator.selectionChanged()
}
#endif #endif
if let showPressedMenuWorkItem {
showPressedMenuWorkItem.cancel()
self.showPressedMenuWorkItem = nil
}
} }
currentSelectedActionIndex = selectedButton?.offset currentSelectedActionIndex = selectedButton?.offset
selectedButton?.element.backgroundColor = .secondarySystemFill 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) #if !os(visionOS)
generator.prepare() generator.prepare()
#endif #endif

View File

@ -17,7 +17,6 @@ protocol MultiColumnNavigationCustomTargetProviding {
class MultiColumnNavigationController: UIViewController { class MultiColumnNavigationController: UIViewController {
private var isManuallyUpdating = false
private var _viewControllers: [UIViewController] = [] private var _viewControllers: [UIViewController] = []
var viewControllers: [UIViewController] { var viewControllers: [UIViewController] {
get { get {
@ -25,8 +24,7 @@ class MultiColumnNavigationController: UIViewController {
} }
set { set {
_viewControllers = newValue _viewControllers = newValue
if isViewLoaded, if isViewLoaded {
!isManuallyUpdating {
updateViews() updateViews()
scrollToEnd(animated: false) scrollToEnd(animated: false)
} }
@ -143,7 +141,14 @@ class MultiColumnNavigationController: UIViewController {
} }
private func scrollColumnToEnd(columnIndex: Int, animated: Bool) { 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() scrollView.layoutIfNeeded()
if animated {
scrollView.contentOffset = origContentOffset
}
let column = stackView.arrangedSubviews[columnIndex] let column = stackView.arrangedSubviews[columnIndex]
let columnFrame = column.convert(column.bounds, to: scrollView) let columnFrame = column.convert(column.bounds, to: scrollView)
let offset: CGFloat let offset: CGFloat

View File

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

View File

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

View File

@ -22,6 +22,7 @@ class GifvPlayerView: UIView {
let controller: GifvController let controller: GifvController
private var presentationSizeCancellable: AnyCancellable? private var presentationSizeCancellable: AnyCancellable?
private var wasPlayingWhenSceneBackgrounded = false
override var intrinsicContentSize: CGSize { override var intrinsicContentSize: CGSize {
controller.item.presentationSize controller.item.presentationSize
@ -45,4 +46,30 @@ class GifvPlayerView: UIView {
fatalError("init(coder:) has not been implemented") 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 minHeight: CGFloat = 35
private static let cornerRadius = 0.1 * minHeight private static let cornerRadius = 0.1 * minHeight
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25) 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! private(set) var label: EmojiLabel!
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure { @Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure {
@ -33,6 +34,12 @@ class PollOptionView: UIView {
private var labelLeadingToSelfConstraint: NSLayoutConstraint! private var labelLeadingToSelfConstraint: NSLayoutConstraint!
private var fillViewWidthConstraint: NSLayoutConstraint? private var fillViewWidthConstraint: NSLayoutConstraint?
var hovered: Bool = false {
didSet {
backgroundColor = hovered ? PollOptionView.hoveredBackgroundColor : PollOptionView.unselectedBackgroundColor
}
}
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)

View File

@ -24,12 +24,19 @@ class PollOptionsView: UIControl {
private var poll: Poll! private var poll: Poll!
private var animator: UIViewPropertyAnimator! private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int? private var currentSelectedOptionIndex: Int?
private var currentHoveredOptionIndex: Int?
private let animationDuration: TimeInterval = 0.1 static let animationDuration: TimeInterval = 0.1
private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95) static let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
#if !os(visionOS) #if !os(visionOS)
private let generator = UISelectionFeedbackGenerator() private lazy var generator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif #endif
override var isEnabled: Bool { override var isEnabled: Bool {
@ -59,6 +66,8 @@ class PollOptionsView: UIControl {
stack.topAnchor.constraint(equalTo: topAnchor), stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
} }
required init?(coder: NSCoder) { 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 // MARK: - UIControl
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { 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) { if view.point(inside: touch.location(in: view), with: event) {
currentSelectedOptionIndex = index currentSelectedOptionIndex = index
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { if animator?.isRunning == true {
view.transform = self.scaledTransform animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
view.transform = Self.scaledTransform
view.hovered = true
} }
animator.startAnimation() animator.startAnimation()
#if !os(visionOS) #if !os(visionOS)
if #available(iOS 17.5, *) {
generator.selectionChanged(at: view.center)
} else {
generator.selectionChanged() generator.selectionChanged()
}
generator.prepare() generator.prepare()
#endif #endif
@ -151,30 +182,31 @@ class PollOptionsView: UIControl {
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let location = touch.location(in: self) let location = touch.location(in: self)
var newIndex: Int? = nil let newIndexAndOption = optionView(at: location)
for (index, view) in options.enumerated() { let newIndex = newIndexAndOption?.1
var frame = view.frame let option = newIndexAndOption?.0
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
}
}
if newIndex != currentSelectedOptionIndex { if newIndex != currentSelectedOptionIndex {
currentSelectedOptionIndex = newIndex 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() { 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 !os(visionOS)
if newIndex != nil { if let option {
if #available(iOS 17.5, *) {
generator.selectionChanged(at: option.center)
} else {
generator.selectionChanged() generator.selectionChanged()
}
generator.prepare() generator.prepare()
} }
#endif #endif
@ -189,14 +221,15 @@ class PollOptionsView: UIControl {
func selectOption() { func selectOption() {
guard let index = currentSelectedOptionIndex else { return } guard let index = currentSelectedOptionIndex else { return }
let option = options[index] let option = options[index]
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) { animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
option.transform = .identity option.transform = .identity
option.hovered = false
self.selectOption(option) self.selectOption(option)
} }
animator.startAnimation() animator.startAnimation()
} }
if animator.isRunning { if animator?.isRunning == true {
animator.addCompletion { (_) in animator.addCompletion { (_) in
selectOption() selectOption()
} }
@ -207,12 +240,52 @@ class PollOptionsView: UIControl {
override func cancelTracking(with event: UIEvent?) { override func cancelTracking(with event: UIEvent?) {
super.cancelTracking(with: event) 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 { for view in self.options {
view.transform = .identity 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) addSubview(infoLabel)
voteButton = UIButton(configuration: .plain()) voteButton = UIButton(configuration: .plain())
voteButton.isPointerInteractionEnabled = true
voteButton.translatesAutoresizingMaskIntoConstraints = false voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside) voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)

View File

@ -24,7 +24,13 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
private var changeSelectionPanRecognizer: UIGestureRecognizer! private var changeSelectionPanRecognizer: UIGestureRecognizer!
private var selectedOptionAtStartOfPan: Value? private var selectedOptionAtStartOfPan: Value?
#if !os(visionOS) #if !os(visionOS)
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator() private lazy var selectionChangedFeedbackGenerator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif #endif
override var intrinsicContentSize: CGSize { override var intrinsicContentSize: CGSize {
@ -111,14 +117,20 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
func setSelectedOption(_ value: Value, animated: Bool) { func setSelectedOption(_ value: Value, animated: Bool) {
guard selectedOption != value, guard selectedOption != value,
options.contains(where: { $0.value == value }) else { let index = options.firstIndex(where: { $0.value == value }) else {
return return
} }
#if !os(visionOS) #if !os(visionOS)
if selectedOption != nil { if selectedOption != nil {
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() selectionChangedFeedbackGenerator.selectionChanged()
} }
}
#endif #endif
selectedOption = value selectedOption = value
@ -158,15 +170,19 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
// MARK: Interaction // MARK: Interaction
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer === self.panGestureRecognizer else {
return true
}
let beganOnSelectedOption: Bool let beganOnSelectedOption: Bool
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }), 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 beganOnSelectedOption = true
} else { } else {
beganOnSelectedOption = false 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 // otherwise, let the scroll view handle things
if gestureRecognizer == self.changeSelectionPanRecognizer { if gestureRecognizer == self.changeSelectionPanRecognizer {
return beganOnSelectedOption return beganOnSelectedOption
@ -223,7 +239,12 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
} }
animator.startAnimation() animator.startAnimation()
#if !os(visionOS) #if !os(visionOS)
if #available(iOS 17.5, *) {
let locationInSelf = convert(location, from: optionsStack)
selectionChangedFeedbackGenerator.selectionChanged(at: locationInSelf)
} else {
selectionChangedFeedbackGenerator.selectionChanged() selectionChangedFeedbackGenerator.selectionChanged()
}
#endif #endif
return true return true
} else { } else {

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
class WeakHolder<T: AnyObject> { final class WeakHolder<T: AnyObject> {
weak var object: T? weak var object: T?
init(_ 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>] private var array: [WeakHolder<Element>]
var startIndex: Int { array.startIndex } var startIndex: Int { array.startIndex }
@ -47,6 +47,10 @@ struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollect
return array.index(after: i) 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 { 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) }) array.replaceSubrange(subrange, with: newElements.map { WeakHolder($0) })
} }

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.3 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 = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev