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:
commit
7ee51d06cc
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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 */,
|
||||
|
78
Mastodon/Scene/MediaPreview/AltViewController.swift
Normal file
78
Mastodon/Scene/MediaPreview/AltViewController.swift
Normal 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
|
||||
}
|
||||
}
|
@ -68,30 +68,21 @@ extension MediaPreviewImageViewController {
|
||||
let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||
previewImageView.addInteraction(previewImageViewContextMenuInteraction)
|
||||
|
||||
switch viewModel.item {
|
||||
case .remote(let imageContext):
|
||||
previewImageView.imageView.accessibilityLabel = imageContext.altText
|
||||
|
||||
if let thumbnail = imageContext.thumbnail {
|
||||
previewImageView.imageView.image = thumbnail
|
||||
previewImageView.setup(image: thumbnail, container: self.previewImageView, forceUpdate: true)
|
||||
}
|
||||
|
||||
previewImageView.imageView.setImage(
|
||||
url: imageContext.assetURL,
|
||||
placeholder: imageContext.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)
|
||||
|
||||
previewImageView.imageView.accessibilityLabel = viewModel.item.altText
|
||||
|
||||
if let thumbnail = viewModel.item.thumbnail {
|
||||
previewImageView.imageView.image = thumbnail
|
||||
previewImageView.setup(image: thumbnail, container: self.previewImageView, forceUpdate: true)
|
||||
}
|
||||
|
||||
previewImageView.imageView.setImage(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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) } }
|
||||
|
||||
@ -26,24 +24,23 @@ 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)
|
||||
@ -67,35 +64,30 @@ extension MediaPreviewViewController {
|
||||
visualEffectView.contentView.addSubview(pagingViewController.view)
|
||||
visualEffectView.pinTo(to: pagingViewController.view)
|
||||
pagingViewController.didMove(toParent: self)
|
||||
|
||||
closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(closeButtonBackground)
|
||||
NSLayoutConstraint.activate([
|
||||
closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12),
|
||||
closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor)
|
||||
])
|
||||
closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView)
|
||||
|
||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton)
|
||||
view.addSubview(topToolbar)
|
||||
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),
|
||||
topToolbar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12),
|
||||
topToolbar.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
topToolbar.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
|
||||
])
|
||||
|
||||
|
||||
topToolbar.addArrangedSubview(closeButton)
|
||||
NSLayoutConstraint.activate([
|
||||
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
|
||||
.receive(on: DispatchQueue.main)
|
||||
@ -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,13 +326,8 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate {
|
||||
let activityViewController = UIActivityViewController(
|
||||
activityItems: {
|
||||
var activityItems: [Any] = []
|
||||
switch viewController.viewModel.item {
|
||||
case .remote(let previewContext):
|
||||
if let assetURL = previewContext.assetURL {
|
||||
activityItems.append(assetURL)
|
||||
}
|
||||
case .local(let previewContext):
|
||||
activityItems.append(previewContext.image)
|
||||
if let assetURL = viewController.viewModel.item.assetURL {
|
||||
activityItems.append(assetURL)
|
||||
}
|
||||
return activityItems
|
||||
}(),
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -81,8 +81,8 @@ 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
|
||||
|
14
MastodonSDK/Sources/MastodonExtension/UIEdgeInsets.swift
Normal file
14
MastodonSDK/Sources/MastodonExtension/UIEdgeInsets.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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: %@";
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
77
MastodonSDK/Sources/MastodonUI/View/Button/HUDButton.swift
Normal file
77
MastodonSDK/Sources/MastodonUI/View/Button/HUDButton.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
//
|
||||
// TouchTransparentStackView.swift
|
||||
//
|
||||
//
|
||||
// Created by Jed Fox on 2022-12-21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// A subclass of `UIStackView` that allows touches that aren’t 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 view’s 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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}()
|
||||
|
@ -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,8 +168,10 @@ extension MediaView {
|
||||
self.imageView.image = image
|
||||
}
|
||||
.store(in: &configuration.disposeBag)
|
||||
|
||||
bindAlt(configuration: configuration, altDescription: info.altDescription)
|
||||
}
|
||||
|
||||
|
||||
private func layoutGIF() {
|
||||
// use view controller as View here
|
||||
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user