1
0
mirror of https://github.com/mastodon/mastodon-ios.git synced 2025-02-03 18:57:46 +01:00

Merge pull request #699 from j-f1/alt-button

Add a button to show alt text for media
This commit is contained in:
Nathan Mattes 2022-12-22 09:29:00 +01:00 committed by GitHub
commit 7ee51d06cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 524 additions and 170 deletions

View File

@ -187,6 +187,12 @@
"unknown_language": "Unknown",
"unknown_provider": "Unknown",
"show_original": "Shown Original"
},
"media": {
"accessibility_label": "%s, attachment %d of %d",
"expand_image_hint": "Expands the image. Double-tap and hold to show actions",
"expand_gif_hint": "Expands the GIF. Double-tap and hold to show actions",
"expand_video_hint": "Shows the video player. Double-tap and hold to show actions"
}
},
"friendship": {

View File

@ -187,6 +187,12 @@
"unknown_language": "Unknown",
"unknown_provider": "Unknown",
"show_original": "Shown Original"
},
"media": {
"accessibility_label": "%s, attachment %d of %d",
"expand_image_hint": "Expands the image. Double-tap and hold to show actions",
"expand_gif_hint": "Expands the GIF. Double-tap and hold to show actions",
"expand_video_hint": "Shows the video player. Double-tap and hold to show actions"
}
},
"friendship": {

View File

@ -98,6 +98,7 @@
62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */; };
85904C02293BC0EB0011C817 /* ImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C01293BC0EB0011C817 /* ImageProvider.swift */; };
85904C04293BC1940011C817 /* URLActivityItemWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */; };
85BC11B32932414900E191CD /* AltViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BC11B22932414900E191CD /* AltViewController.swift */; };
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
C24C97032922F30500BAE8CB /* RefreshControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24C97022922F30500BAE8CB /* RefreshControl.swift */; };
D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; };
@ -620,6 +621,7 @@
819CEC9DCAD8E8E7BD85A7BB /* Pods-Mastodon.asdk.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.asdk.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.asdk.xcconfig"; sourceTree = "<group>"; };
85904C01293BC0EB0011C817 /* ImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProvider.swift; sourceTree = "<group>"; };
85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivityItemWithMetadata.swift; sourceTree = "<group>"; };
85BC11B22932414900E191CD /* AltViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltViewController.swift; sourceTree = "<group>"; };
8850E70A1D5FF51432E43653 /* Pods-Mastodon-MastodonUITests.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; sourceTree = "<group>"; };
8E79CCBE51FBC3F7FE8CF49F /* Pods-MastodonTests.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release snapshot.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release snapshot.xcconfig"; sourceTree = "<group>"; };
8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
@ -1975,6 +1977,7 @@
DB6180F026391CAB0018D199 /* Image */,
DB6180E1263919780018D199 /* Paging */,
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */,
85BC11B22932414900E191CD /* AltViewController.swift */,
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */,
);
path = MediaPreview;
@ -3535,6 +3538,7 @@
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
DB3EA8F1281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift in Sources */,
85BC11B32932414900E191CD /* AltViewController.swift in Sources */,
DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */,
DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */,
DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */,

View File

@ -0,0 +1,78 @@
//
// AltViewController.swift
// Mastodon
//
// Created by Jed Fox on 2022-11-26.
//
import SwiftUI
class AltViewController: UIViewController {
private var alt: String
let label = UITextView()
init(alt: String, sourceView: UIView?) {
self.alt = alt
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .popover
self.popoverPresentationController?.delegate = self
self.popoverPresentationController?.permittedArrowDirections = .up
self.popoverPresentationController?.sourceView = sourceView
self.overrideUserInterfaceStyle = .dark
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
label.translatesAutoresizingMaskIntoConstraints = false
label.textContainer.maximumNumberOfLines = 0
label.textContainer.lineBreakMode = .byWordWrapping
label.textContainerInset = UIEdgeInsets(
top: 8,
left: 0,
bottom: -label.textContainer.lineFragmentPadding,
right: 0
)
label.font = .preferredFont(forTextStyle: .callout)
label.isScrollEnabled = false
label.backgroundColor = .clear
label.isOpaque = false
label.isEditable = false
label.tintColor = .white
label.text = alt
view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate(
NSLayoutConstraint.constraints(withVisualFormat: "V:|-[label]-|", metrics: nil, views: ["label": label])
)
NSLayoutConstraint.activate(
NSLayoutConstraint.constraints(withVisualFormat: "H:|-(8)-[label]-(8)-|", metrics: nil, views: ["label": label])
)
NSLayoutConstraint.activate([
label.widthAnchor.constraint(lessThanOrEqualToConstant: 400),
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
UIView.performWithoutAnimation {
preferredContentSize = CGSize(
width: label.intrinsicContentSize.width + 16,
height: label.intrinsicContentSize.height + view.layoutMargins.top + view.layoutMargins.bottom
)
}
}
}
// MARK: UIPopoverPresentationControllerDelegate
extension AltViewController: UIPopoverPresentationControllerDelegate {
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
.none
}
}

View File

@ -68,31 +68,22 @@ extension MediaPreviewImageViewController {
let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self)
previewImageView.addInteraction(previewImageViewContextMenuInteraction)
switch viewModel.item {
case .remote(let imageContext):
previewImageView.imageView.accessibilityLabel = imageContext.altText
previewImageView.imageView.accessibilityLabel = viewModel.item.altText
if let thumbnail = imageContext.thumbnail {
if let thumbnail = viewModel.item.thumbnail {
previewImageView.imageView.image = thumbnail
previewImageView.setup(image: thumbnail, container: self.previewImageView, forceUpdate: true)
}
previewImageView.imageView.setImage(
url: imageContext.assetURL,
placeholder: imageContext.thumbnail,
url: viewModel.item.assetURL,
placeholder: viewModel.item.thumbnail,
scaleToSize: nil
) { [weak self] image in
guard let self = self else { return }
guard let image = image else { return }
self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true)
}
case .local(let imageContext):
let image = imageContext.image
previewImageView.imageView.image = image
previewImageView.setup(image: image, container: previewImageView, forceUpdate: true)
}
}
}

View File

@ -30,19 +30,10 @@ class MediaPreviewImageViewModel {
extension MediaPreviewImageViewModel {
public enum ImagePreviewItem {
case remote(RemoteImageContext)
case local(LocalImageContext)
}
public struct RemoteImageContext {
public struct ImagePreviewItem {
let assetURL: URL?
let thumbnail: UIImage?
let altText: String?
}
public struct LocalImageContext {
let image: UIImage
}
}

View File

@ -16,8 +16,6 @@ import MastodonLocalization
final class MediaPreviewViewController: UIViewController, NeedsDependency {
static let closeButtonSize = CGSize(width: 30, height: 30)
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -27,23 +25,22 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency {
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
let pagingViewController = MediaPreviewPagingViewController()
let closeButtonBackground: UIVisualEffectView = {
let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
backgroundView.alpha = 0.9
backgroundView.layer.masksToBounds = true
backgroundView.layer.cornerRadius = MediaPreviewViewController.closeButtonSize.width * 0.5
return backgroundView
let topToolbar: UIStackView = {
let stackView = TouchTransparentStackView()
stackView.axis = .horizontal
stackView.distribution = .equalSpacing
stackView.alignment = .fill
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial)))
let closeButton: UIButton = {
let button = HighlightDimmableButton()
button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
button.imageView?.tintColor = .label
let closeButton = HUDButton { button in
button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal)
return button
}()
}
let altButton = HUDButton { button in
button.setTitle("ALT", for: .normal)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
@ -68,33 +65,28 @@ extension MediaPreviewViewController {
visualEffectView.pinTo(to: pagingViewController.view)
pagingViewController.didMove(toParent: self)
closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(closeButtonBackground)
view.addSubview(topToolbar)
NSLayoutConstraint.activate([
closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12),
closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor)
topToolbar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12),
topToolbar.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
topToolbar.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
])
closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView)
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton)
topToolbar.addArrangedSubview(closeButton)
NSLayoutConstraint.activate([
closeButton.topAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.topAnchor),
closeButton.leadingAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.leadingAnchor),
closeButtonBackgroundVisualEffectView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor),
closeButtonBackgroundVisualEffectView.bottomAnchor.constraint(equalTo: closeButton.bottomAnchor),
closeButton.heightAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.height).priority(.defaultHigh),
closeButton.widthAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.width).priority(.defaultHigh),
closeButton.widthAnchor.constraint(equalToConstant: HUDButton.height).priority(.defaultHigh),
])
topToolbar.addArrangedSubview(altButton)
viewModel.mediaPreviewImageViewControllerDelegate = self
pagingViewController.interPageSpacing = 10
pagingViewController.delegate = self
pagingViewController.dataSource = viewModel
closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside)
closeButton.button.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside)
altButton.button.addTarget(self, action: #selector(MediaPreviewViewController.altButtonPressed(_:)), for: .touchUpInside)
// bind view model
viewModel.$currentPage
@ -126,20 +118,34 @@ extension MediaPreviewViewController {
let attachment = previewContext.attachments[index]
return attachment.kind == .video // not hide buttno for audio
}()
self.closeButtonBackground.isHidden = needsHideCloseButton
self.closeButton.isHidden = needsHideCloseButton
default:
break
}
}
.store(in: &disposeBag)
viewModel.$altText
.receive(on: DispatchQueue.main)
.sink { [weak self] altText in
guard let self else { return }
UIView.animate(withDuration: 0.3) {
if altText == nil {
self.altButton.alpha = 0
} else {
self.altButton.alpha = 1
}
}
}
.store(in: &disposeBag)
viewModel.$showingChrome
.receive(on: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] showingChrome in
UIView.animate(withDuration: 0.3) {
self?.setNeedsStatusBarAppearanceUpdate()
self?.closeButtonBackground.alpha = showingChrome ? 1 : 0
self?.topToolbar.alpha = showingChrome ? 1 : 0
}
}
.store(in: &disposeBag)
@ -170,10 +176,14 @@ extension MediaPreviewViewController {
extension MediaPreviewViewController {
@objc private func closeButtonPressed(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
dismiss(animated: true, completion: nil)
}
@objc private func altButtonPressed(_ sender: UIButton) {
guard let alt = viewModel.altText else { return }
present(AltViewController(alt: alt, sourceView: sender), animated: true)
}
}
// MARK: - MediaPreviewingViewController
@ -270,19 +280,8 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
) {
switch action {
case .savePhoto:
let _savePublisher: AnyPublisher<Void, Error>? = {
switch viewController.viewModel.item {
case .remote(let previewContext):
guard let assetURL = previewContext.assetURL else { return nil }
return context.photoLibraryService.save(imageSource: .url(assetURL))
case .local(let previewContext):
return context.photoLibraryService.save(imageSource: .image(previewContext.image))
}
}()
guard let savePublisher = _savePublisher else {
return
}
savePublisher
guard let assetURL = viewController.viewModel.item.assetURL else { return }
context.photoLibraryService.save(imageSource: .url(assetURL))
.sink { [weak self] completion in
guard let self = self else { return }
switch completion {
@ -306,20 +305,9 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
}
.store(in: &context.disposeBag)
case .copyPhoto:
let _copyPublisher: AnyPublisher<Void, Error>? = {
switch viewController.viewModel.item {
case .remote(let previewContext):
guard let assetURL = previewContext.assetURL else { return nil }
return context.photoLibraryService.copy(imageSource: .url(assetURL))
case .local(let previewContext):
return context.photoLibraryService.copy(imageSource: .image(previewContext.image))
}
}()
guard let copyPublisher = _copyPublisher else {
return
}
guard let assetURL = viewController.viewModel.item.assetURL else { return }
copyPublisher
context.photoLibraryService.copy(imageSource: .url(assetURL))
.sink { completion in
switch completion {
case .failure(let error):
@ -338,14 +326,9 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
let activityViewController = UIActivityViewController(
activityItems: {
var activityItems: [Any] = []
switch viewController.viewModel.item {
case .remote(let previewContext):
if let assetURL = previewContext.assetURL {
if let assetURL = viewController.viewModel.item.assetURL {
activityItems.append(assetURL)
}
case .local(let previewContext):
activityItems.append(previewContext.image)
}
return activityItems
}(),
applicationActivities: applicationActivities

View File

@ -27,9 +27,10 @@ final class MediaPreviewViewModel: NSObject {
@Published var currentPage: Int
@Published var showingChrome = true
@Published var altText: String?
// output
let viewControllers: [UIViewController]
let viewControllers: [MediaPreviewPage]
private var disposeBag: Set<AnyCancellable> = []
@ -42,8 +43,11 @@ final class MediaPreviewViewModel: NSObject {
self.item = item
var currentPage = 0
var viewControllers: [MediaPreviewPage] = []
var getAltText = { (page: Int) -> String? in nil }
switch item {
case .attachment(let previewContext):
getAltText = { previewContext.attachments[$0].altDescription }
currentPage = previewContext.initialIndex
for (i, attachment) in previewContext.attachments.enumerated() {
switch attachment.kind {
@ -51,11 +55,11 @@ final class MediaPreviewViewModel: NSObject {
let viewController = MediaPreviewImageViewController()
let viewModel = MediaPreviewImageViewModel(
context: context,
item: .remote(.init(
item: .init(
assetURL: attachment.assetURL.flatMap { URL(string: $0) },
thumbnail: previewContext.thumbnail(at: i),
altText: attachment.altDescription
))
)
)
viewController.viewModel = viewModel
viewControllers.append(viewController)
@ -65,7 +69,8 @@ final class MediaPreviewViewModel: NSObject {
context: context,
item: .gif(.init(
assetURL: attachment.assetURL.flatMap { URL(string: $0) },
previewURL: attachment.previewURL.flatMap { URL(string: $0) }
previewURL: attachment.previewURL.flatMap { URL(string: $0) },
altText: attachment.altDescription
))
)
viewController.viewModel = viewModel
@ -76,7 +81,8 @@ final class MediaPreviewViewModel: NSObject {
context: context,
item: .video(.init(
assetURL: attachment.assetURL.flatMap { URL(string: $0) },
previewURL: attachment.previewURL.flatMap { URL(string: $0) }
previewURL: attachment.previewURL.flatMap { URL(string: $0) },
altText: attachment.altDescription
))
)
viewController.viewModel = viewModel
@ -87,11 +93,11 @@ final class MediaPreviewViewModel: NSObject {
let viewController = MediaPreviewImageViewController()
let viewModel = MediaPreviewImageViewModel(
context: context,
item: .remote(.init(
item: .init(
assetURL: previewContext.assetURL.flatMap { URL(string: $0) },
thumbnail: previewContext.thumbnail,
altText: nil
))
)
)
viewController.viewModel = viewModel
viewControllers.append(viewController)
@ -99,11 +105,11 @@ final class MediaPreviewViewModel: NSObject {
let viewController = MediaPreviewImageViewController()
let viewModel = MediaPreviewImageViewModel(
context: context,
item: .remote(.init(
item: .init(
assetURL: previewContext.assetURL.flatMap { URL(string: $0) },
thumbnail: previewContext.thumbnail,
altText: nil
))
)
)
viewController.viewModel = viewModel
viewControllers.append(viewController)
@ -114,6 +120,10 @@ final class MediaPreviewViewModel: NSObject {
self.transitionItem = transitionItem
super.init()
self.$currentPage
.map(getAltText)
.assign(to: &$altText)
for viewController in viewControllers {
self.$showingChrome
.sink { [weak viewController] showingChrome in

View File

@ -130,12 +130,14 @@ extension MediaPreviewVideoViewModel {
struct RemoteVideoContext {
let assetURL: URL?
let previewURL: URL?
let altText: String?
// let thumbnail: UIImage?
}
struct RemoteGIFContext {
let assetURL: URL?
let previewURL: URL?
let altText: String?
}
}

View File

@ -81,7 +81,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
transitionItem.transitionView = transitionImageView
transitionContext.containerView.addSubview(transitionImageView)
toVC.closeButtonBackground.alpha = 0
toVC.topToolbar.alpha = 0
if UIAccessibility.isReduceTransparencyEnabled {
toVC.visualEffectView.alpha = 0
@ -101,7 +101,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
toVC.pagingViewController.view.alpha = 1
transitionImageView.removeFromSuperview()
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
toVC.closeButtonBackground.alpha = 1
toVC.topToolbar.alpha = 1
}
transitionContext.completeTransition(position == .end)
}
@ -138,13 +138,13 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
}
}
// update close button
// update top toolbar
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
fromVC.closeButtonBackground.alpha = 0
fromVC.topToolbar.alpha = 0
}
animator.addCompletion { position in
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseInOut]) {
fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1
fromVC.topToolbar.alpha = position == .end ? 0 : 1
}
}
@ -202,7 +202,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning {
mediaPreviewTransitionContext.snapshot.contentMode = .scaleAspectFill
mediaPreviewTransitionContext.snapshot.clipsToBounds = true
transitionMaskView.addSubview(mediaPreviewTransitionContext.snapshot)
fromVC.view.bringSubviewToFront(fromVC.closeButtonBackground)
fromVC.view.bringSubviewToFront(fromVC.topToolbar)
transitionItem.transitionView = mediaPreviewTransitionContext.transitionView
transitionItem.snapshotTransitioning = mediaPreviewTransitionContext.snapshot

View File

@ -0,0 +1,14 @@
//
// UIEdgeInsets.swift
//
//
// Created by Jed Fox on 2022-11-24.
//
import UIKit
extension UIEdgeInsets {
public static func constant(_ offset: CGFloat) -> Self {
UIEdgeInsets(top: offset, left: offset, bottom: offset, right: offset)
}
}

View File

@ -48,20 +48,19 @@ extension UIView {
}
public extension UIView {
@discardableResult
func pinToParent() -> [NSLayoutConstraint] {
pinTo(to: self.superview)
func pinToParent(padding: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
pinTo(to: self.superview, padding: padding)
}
@discardableResult
func pinTo(to view: UIView?) -> [NSLayoutConstraint] {
func pinTo(to view: UIView?, padding: UIEdgeInsets = .zero) -> [NSLayoutConstraint] {
guard let pinToView = view else { return [] }
let constraints = [
topAnchor.constraint(equalTo: pinToView.topAnchor),
leadingAnchor.constraint(equalTo: pinToView.leadingAnchor),
trailingAnchor.constraint(equalTo: pinToView.trailingAnchor),
bottomAnchor.constraint(equalTo: pinToView.bottomAnchor),
topAnchor.constraint(equalTo: pinToView.topAnchor, constant: padding.top),
leadingAnchor.constraint(equalTo: pinToView.leadingAnchor, constant: padding.left),
trailingAnchor.constraint(equalTo: pinToView.trailingAnchor, constant: -padding.right),
bottomAnchor.constraint(equalTo: pinToView.bottomAnchor, constant: -padding.bottom),
]
NSLayoutConstraint.activate(constraints)
return constraints

View File

@ -340,6 +340,18 @@ public enum L10n {
/// Undo reblog
public static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog", fallback: "Undo reblog")
}
public enum Media {
/// %@, attachment %d of %d
public static func accessibilityLabel(_ p1: Any, _ p2: Int, _ p3: Int) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.Media.AccessibilityLabel", String(describing: p1), p2, p3, fallback: "%@, attachment %d of %d")
}
/// Expands the GIF. Double-tap and hold to show actions
public static let expandGifHint = L10n.tr("Localizable", "Common.Controls.Status.Media.ExpandGifHint", fallback: "Expands the GIF. Double-tap and hold to show actions")
/// Expands the image. Double-tap and hold to show actions
public static let expandImageHint = L10n.tr("Localizable", "Common.Controls.Status.Media.ExpandImageHint", fallback: "Expands the image. Double-tap and hold to show actions")
/// Shows the video player. Double-tap and hold to show actions
public static let expandVideoHint = L10n.tr("Localizable", "Common.Controls.Status.Media.ExpandVideoHint", fallback: "Shows the video player. Double-tap and hold to show actions")
}
public enum MetaEntity {
/// Email address: %@
public static func email(_ p1: Any) -> String {

View File

@ -116,6 +116,10 @@ Please check your internet connection.";
"Common.Controls.Status.ContentWarning" = "Content Warning";
"Common.Controls.Status.LinkViaUser" = "%@ via %@";
"Common.Controls.Status.LoadEmbed" = "Load Embed";
"Common.Controls.Status.Media.AccessibilityLabel" = "%@, attachment %d of %d";
"Common.Controls.Status.Media.ExpandGifHint" = "Expands the GIF. Double-tap and hold to show actions";
"Common.Controls.Status.Media.ExpandImageHint" = "Expands the image. Double-tap and hold to show actions";
"Common.Controls.Status.Media.ExpandVideoHint" = "Shows the video player. Double-tap and hold to show actions";
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
"Common.Controls.Status.MetaEntity.Email" = "Email address: %@";
"Common.Controls.Status.MetaEntity.Hashtag" = "Hashtag: %@";

View File

@ -0,0 +1,15 @@
//
// CGSize.swift
//
//
// Created by Jed Fox on 2022-12-20.
//
import Foundation
extension CGSize: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(width)
hasher.combine(height)
}
}

View File

@ -0,0 +1,77 @@
//
// HUDButton.swift
// Mastodon
//
// Created by Jed Fox on 2022-11-24.
//
import UIKit
public class HUDButton: UIView {
public static let height: CGFloat = 30
let background: UIVisualEffectView = {
let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
backgroundView.alpha = 0.9
backgroundView.layer.masksToBounds = true
backgroundView.layer.cornerRadius = HUDButton.height * 0.5
return backgroundView
}()
let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial)))
public let button: UIButton = {
let button = HighlightDimmableButton()
button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
button.contentEdgeInsets = .constant(7)
button.imageView?.tintColor = .label
button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))
return button
}()
public init(configure: (UIButton) -> Void) {
super.init(frame: .zero)
configure(button)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
func _init() {
translatesAutoresizingMaskIntoConstraints = false
background.translatesAutoresizingMaskIntoConstraints = false
addSubview(background)
background.pinToParent()
vibrancyView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
background.contentView.addSubview(vibrancyView)
button.translatesAutoresizingMaskIntoConstraints = false
vibrancyView.contentView.addSubview(button)
button.pinToParent()
NSLayoutConstraint.activate([
heightAnchor.constraint(equalToConstant: HUDButton.height).priority(.defaultHigh),
])
}
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))
}
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
button.point(inside: button.convert(point, from: self), with: event)
}
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.point(inside: point, with: event) {
return button
} else {
return nil
}
}
}

View File

@ -0,0 +1,26 @@
//
// TouchTransparentStackView.swift
//
//
// Created by Jed Fox on 2022-12-21.
//
import UIKit
/// A subclass of `UIStackView` that allows touches that arent captured by any
/// of its subviews to pass through to views beneath this view in the Z-order.
public class TouchTransparentStackView: UIStackView {
// allow subview hit boxes to grow outside of this views bounds
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
subviews.contains { $0.point(inside: $0.convert(point, from: self), with: event) }
}
// allow taps on blank areas to pass through
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == self {
return nil
}
return view
}
}

View File

@ -0,0 +1,80 @@
//
// MediaAltTextOverlay.swift
//
//
// Created by Jed Fox on 2022-12-20.
//
import SwiftUI
@available(iOS 15.0, *)
struct MediaAltTextOverlay: View {
var altDescription: String?
@State private var showingAlt = false
@Namespace private var namespace
var body: some View {
GeometryReader { geom in
ZStack {
if let altDescription {
if showingAlt {
HStack(alignment: .top) {
Text(altDescription)
Spacer()
Button(action: { showingAlt = false }) {
Image(systemName: "xmark.circle.fill")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 20, height: 20)
}
}
.padding(8)
.matchedGeometryEffect(id: "background", in: namespace, properties: .position)
.transition(
.scale(scale: 0.2, anchor: .bottomLeading)
.combined(with: .opacity)
)
} else {
Button("ALT") { showingAlt = true }
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.matchedGeometryEffect(id: "background", in: namespace, properties: .position)
.transition(
.scale(scale: 3, anchor: .trailing)
.combined(with: .opacity)
)
}
}
}
.foregroundColor(.white)
.tint(.white)
.background(Color.black.opacity(0.85))
.cornerRadius(4)
.overlay(
.white.opacity(0.5),
in: RoundedRectangle(cornerRadius: 4)
.inset(by: -0.5)
.stroke(lineWidth: 0.5)
)
.animation(.spring(response: 0.3), value: showingAlt)
.frame(width: geom.size.width, height: geom.size.height, alignment: .bottomLeading)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.onChange(of: altDescription) { _ in
showingAlt = false
}
}
}
@available(iOS 15.0, *)
struct MediaAltTextOverlay_Previews: PreviewProvider {
static var previews: some View {
MediaAltTextOverlay(altDescription: "Hello, world!")
.frame(height: 300)
.background(Color.gray)
.previewLayout(.sizeThatFits)
}
}

View File

@ -21,6 +21,8 @@ extension MediaView {
public let info: Info
public let blurhash: String?
public let index: Int
public let total: Int
@Published public var isReveal = true
@Published public var previewImage: UIImage?
@ -29,10 +31,14 @@ extension MediaView {
public init(
info: MediaView.Configuration.Info,
blurhash: String?
blurhash: String?,
index: Int,
total: Int
) {
self.info = info
self.blurhash = blurhash
self.index = index
self.total = total
}
public var aspectRadio: CGSize {
@ -101,19 +107,16 @@ extension MediaView.Configuration {
public struct ImageInfo: Hashable {
public let aspectRadio: CGSize
public let assetURL: String?
public let altDescription: String?
public init(
aspectRadio: CGSize,
assetURL: String?
assetURL: String?,
altDescription: String?
) {
self.aspectRadio = aspectRadio
self.assetURL = assetURL
}
public func hash(into hasher: inout Hasher) {
hasher.combine(aspectRadio.width)
hasher.combine(aspectRadio.height)
assetURL.flatMap { hasher.combine($0) }
self.altDescription = altDescription
}
}
@ -121,26 +124,21 @@ extension MediaView.Configuration {
public let aspectRadio: CGSize
public let assetURL: String?
public let previewURL: String?
public let altDescription: String?
public let durationMS: Int?
public init(
aspectRadio: CGSize,
assetURL: String?,
previewURL: String?,
altDescription: String?,
durationMS: Int?
) {
self.aspectRadio = aspectRadio
self.assetURL = assetURL
self.previewURL = previewURL
self.durationMS = durationMS
}
public func hash(into hasher: inout Hasher) {
hasher.combine(aspectRadio.width)
hasher.combine(aspectRadio.height)
assetURL.flatMap { hasher.combine($0) }
previewURL.flatMap { hasher.combine($0) }
durationMS.flatMap { hasher.combine($0) }
self.altDescription = altDescription
}
}
@ -187,41 +185,51 @@ extension MediaView {
aspectRadio: attachment.size,
assetURL: attachment.assetURL,
previewURL: attachment.previewURL,
altDescription: attachment.altDescription,
durationMS: attachment.durationMS
)
}
let status = status.reblog ?? status
let attachments = status.attachments
let configurations = attachments.map { attachment -> MediaView.Configuration in
let configurations = attachments.enumerated().map { (idx, attachment) -> MediaView.Configuration in
let configuration: MediaView.Configuration = {
switch attachment.kind {
case .image:
let info = MediaView.Configuration.ImageInfo(
aspectRadio: attachment.size,
assetURL: attachment.assetURL
assetURL: attachment.assetURL,
altDescription: attachment.altDescription
)
return .init(
info: .image(info: info),
blurhash: attachment.blurhash
blurhash: attachment.blurhash,
index: idx,
total: attachments.count
)
case .video:
let info = videoInfo(from: attachment)
return .init(
info: .video(info: info),
blurhash: attachment.blurhash
blurhash: attachment.blurhash,
index: idx,
total: attachments.count
)
case .gifv:
let info = videoInfo(from: attachment)
return .init(
info: .gif(info: info),
blurhash: attachment.blurhash
blurhash: attachment.blurhash,
index: idx,
total: attachments.count
)
case .audio:
let info = videoInfo(from: attachment)
return .init(
info: .video(info: info),
blurhash: attachment.blurhash
blurhash: attachment.blurhash,
index: idx,
total: attachments.count
)
} // end switch
}()

View File

@ -10,18 +10,14 @@ import AVKit
import UIKit
import Combine
import AlamofireImage
import SwiftUI
import MastodonLocalization
public final class MediaView: UIView {
var _disposeBag = Set<AnyCancellable>()
public static let cornerRadius: CGFloat = 0
public static let durationFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.zeroFormattingBehavior = .pad
formatter.allowedUnits = [.minute, .second]
return formatter
}()
public static let placeholderImage = UIImage.placeholder(color: .systemGray6)
public let container = TouchBlockingView()
@ -77,6 +73,20 @@ public final class MediaView: UIView {
return label
}()
let _altViewController: UIViewController! = {
if #available(iOS 15.0, *) {
let vc = UIHostingController(rootView: MediaAltTextOverlay())
vc.view.backgroundColor = .clear
return vc
} else {
return nil
}
}()
@available(iOS 15.0, *)
var altViewController: UIHostingController<MediaAltTextOverlay> {
_altViewController as! UIHostingController<MediaAltTextOverlay>
}
public override init(frame: CGRect) {
super.init(frame: frame)
_init()
@ -118,18 +128,18 @@ extension MediaView {
case .image(let info):
layoutImage()
bindImage(configuration: configuration, info: info)
accessibilityLabel = "Show image" // TODO: i18n
accessibilityHint = L10n.Common.Controls.Status.Media.expandImageHint
case .gif(let info):
layoutGIF()
bindGIF(configuration: configuration, info: info)
accessibilityLabel = "Show GIF" // TODO: i18n
accessibilityHint = L10n.Common.Controls.Status.Media.expandGifHint
case .video(let info):
layoutVideo()
bindVideo(configuration: configuration, info: info)
accessibilityLabel = "Show video player" // TODO: i18n
accessibilityHint = L10n.Common.Controls.Status.Media.expandVideoHint
}
accessibilityHint = "Tap then hold to show menu" // TODO: i18n
accessibilityTraits.insert([.button, .image])
layoutBlurhash()
bindBlurhash(configuration: configuration)
@ -139,6 +149,7 @@ extension MediaView {
imageView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(imageView)
imageView.pinToParent()
layoutAlt()
}
private func bindImage(configuration: Configuration, info: Configuration.ImageInfo) {
@ -157,6 +168,8 @@ extension MediaView {
self.imageView.image = image
}
.store(in: &configuration.disposeBag)
bindAlt(configuration: configuration, altDescription: info.altDescription)
}
private func layoutGIF() {
@ -167,6 +180,8 @@ extension MediaView {
setupIndicatorViewHierarchy()
playerIndicatorLabel.attributedText = NSAttributedString(string: "GIF")
layoutAlt()
}
private func bindGIF(configuration: Configuration, info: Configuration.VideoInfo) {
@ -177,6 +192,8 @@ extension MediaView {
// auto play for GIF
player.play()
bindAlt(configuration: configuration, altDescription: info.altDescription)
}
private func layoutVideo() {
@ -195,11 +212,27 @@ extension MediaView {
private func bindVideo(configuration: Configuration, info: Configuration.VideoInfo) {
let imageInfo = Configuration.ImageInfo(
aspectRadio: info.aspectRadio,
assetURL: info.previewURL
assetURL: info.previewURL,
altDescription: info.altDescription
)
bindImage(configuration: configuration, info: imageInfo)
}
private func bindAlt(configuration: Configuration, altDescription: String?) {
if configuration.total > 1 {
accessibilityLabel = L10n.Common.Controls.Status.Media.accessibilityLabel(
altDescription ?? "",
configuration.index + 1,
configuration.total
)
} else {
accessibilityLabel = altDescription
}
if #available(iOS 15.0, *) {
altViewController.rootView.altDescription = altDescription
}
}
private func layoutBlurhash() {
blurhashImageView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(blurhashImageView)
@ -228,6 +261,14 @@ extension MediaView {
.store(in: &_disposeBag)
}
private func layoutAlt() {
if #available(iOS 15.0, *) {
altViewController.view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(altViewController.view)
altViewController.view.pinToParent()
}
}
public func prepareForReuse() {
_disposeBag.removeAll()
@ -263,6 +304,10 @@ extension MediaView {
container.removeFromSuperview()
container.removeConstraints(container.constraints)
if #available(iOS 15.0, *) {
altViewController.rootView.altDescription = nil
}
// reset configuration
configuration = nil
}

View File

@ -39,9 +39,12 @@ extension NewsView {
let configuration = MediaView.Configuration(
info: .image(info: .init(
aspectRadio: CGSize(width: link.width, height: link.height),
assetURL: link.image
assetURL: link.image,
altDescription: nil
)),
blurhash: link.blurhash
blurhash: link.blurhash,
index: 1,
total: 1
)
imageView.setup(configuration: configuration)