Compare commits
13 Commits
3eceffbb6b
...
cdc64f1b2c
Author | SHA1 | Date |
---|---|---|
Shadowfacts | cdc64f1b2c | |
Shadowfacts | 2913098e74 | |
Shadowfacts | ce99352e90 | |
Shadowfacts | 8322d3a36c | |
Shadowfacts | a818457f8c | |
Shadowfacts | 1f6644b703 | |
Shadowfacts | 412c5ee91d | |
Shadowfacts | dcc5f7f716 | |
Shadowfacts | 9fefc9e8f8 | |
Shadowfacts | d1af911241 | |
Shadowfacts | 5abd265195 | |
Shadowfacts | 3cb0f46533 | |
Shadowfacts | c367a2e9f1 |
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" */ = {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
if #available(iOS 17.5, *) {
|
||||||
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
|
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()
|
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 {
|
||||||
selectionChangedFeedbackGenerator?.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
selectionChangedFeedbackGenerator?.selectionChanged(at: location)
|
||||||
|
} else {
|
||||||
|
selectionChangedFeedbackGenerator?.selectionChanged()
|
||||||
|
}
|
||||||
selectionChangedFeedbackGenerator?.prepare()
|
selectionChangedFeedbackGenerator?.prepare()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -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 {
|
||||||
touchStartLocation = touch.location(in: self)
|
if touch.type == .pencil || touch.type == .indirectPointer {
|
||||||
scrubbingStartFraction = fractionComplete
|
touchStartLocation = .zero
|
||||||
|
scrubbingStartFraction = 0
|
||||||
|
} else {
|
||||||
|
touchStartLocation = touch.location(in: self)
|
||||||
|
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)
|
||||||
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
if #available(iOS 17.5, *) {
|
||||||
|
feedbackGenerator = UIImpactFeedbackGenerator(style: .light, view: self)
|
||||||
|
} else {
|
||||||
|
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,7 +317,11 @@ 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) {
|
||||||
feedbackGenerator!.impactOccurred(intensity: 0.5)
|
if #available(iOS 17.5, *) {
|
||||||
|
feedbackGenerator!.impactOccurred(intensity: 0.5, at: location)
|
||||||
|
} else {
|
||||||
|
feedbackGenerator!.impactOccurred(intensity: 0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
fractionComplete = newFractionComplete
|
fractionComplete = newFractionComplete
|
||||||
|
@ -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?) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
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
|
#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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
generator.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
generator.selectionChanged(at: view.center)
|
||||||
|
} else {
|
||||||
|
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 {
|
||||||
generator.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
generator.selectionChanged(at: option.center)
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,13 +117,19 @@ 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 {
|
||||||
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
|
#endif
|
||||||
|
|
||||||
|
@ -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)
|
||||||
selectionChangedFeedbackGenerator.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
let locationInSelf = convert(location, from: optionsStack)
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged(at: locationInSelf)
|
||||||
|
} else {
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged()
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -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) })
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue