From 9bb7ae0609cc7c9ccf4891403cb62ff770981783 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Wed, 21 Oct 2020 01:07:13 -0700 Subject: [PATCH] Image viewing --- Metatext.xcodeproj/project.pbxproj | 14 +- .../ImageNavigationController.swift | 25 +++ .../ImagePageViewController.swift | 76 ++++++++ View Controllers/ImageViewController.swift | 169 ++++++++++++++++++ View Controllers/TableViewController.swift | 7 +- Views/PlayerView.swift | 12 +- Views/Status/StatusAttachmentView.swift | 1 + Views/ViewConstants.swift | 1 + 8 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 View Controllers/ImageNavigationController.swift create mode 100644 View Controllers/ImagePageViewController.swift create mode 100644 View Controllers/ImageViewController.swift diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index c39a0f0..6c2df18 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; }; D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; }; + D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; }; + D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */; }; + D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; @@ -116,6 +119,9 @@ D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; + D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; + D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePageViewController.swift; sourceTree = ""; }; + D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageNavigationController.swift; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPreferences+Extensions.swift"; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; @@ -297,7 +303,6 @@ D0F0B125251A90F400942152 /* AccountListCell.swift */, D0F0B10D251A868200942152 /* AccountView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, - D03B1B29253818F3008F964B /* MediaPreferencesView.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, @@ -307,6 +312,7 @@ D0B8510B25259E56004E0744 /* LoadMoreCell.swift */, D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */, D0E569DA2529319100FA1D72 /* LoadMoreView.swift */, + D03B1B29253818F3008F964B /* MediaPreferencesView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0FE1C8E253686F9003EF1EB /* PlayerView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, @@ -328,6 +334,9 @@ D0C7D43024F76169001EBDBB /* View Controllers */ = { isa = PBXGroup; children = ( + D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, + D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, + D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, D06BC5E525202AD90079541D /* ProfileViewController.swift */, D0F0B12D251A97E400942152 /* TableViewController.swift */, ); @@ -569,6 +578,7 @@ D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, + D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */, @@ -591,9 +601,11 @@ D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */, D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, + D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */, D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */, D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, + D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */, D0030982250C6C8500EACB32 /* URL+Extensions.swift in Sources */, D00CB2ED2533ACC00080096B /* StatusView.swift in Sources */, D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */, diff --git a/View Controllers/ImageNavigationController.swift b/View Controllers/ImageNavigationController.swift new file mode 100644 index 0000000..31af1e7 --- /dev/null +++ b/View Controllers/ImageNavigationController.swift @@ -0,0 +1,25 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class ImageNavigationController: UINavigationController { + private let imagePageViewController: ImagePageViewController + + init(imagePageViewController: ImagePageViewController) { + self.imagePageViewController = imagePageViewController + + super.init(rootViewController: imagePageViewController) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + hidesBarsOnTap = true + modalPresentationStyle = .fullScreen + } +} diff --git a/View Controllers/ImagePageViewController.swift b/View Controllers/ImagePageViewController.swift new file mode 100644 index 0000000..f317c16 --- /dev/null +++ b/View Controllers/ImagePageViewController.swift @@ -0,0 +1,76 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +class ImagePageViewController: UIPageViewController { + let imageViewControllers: [ImageViewController] + + init(initiallyVisible: AttachmentViewModel, statusViewModel: StatusViewModel) { + imageViewControllers = statusViewModel.attachmentViewModels.map(ImageViewController.init(viewModel:)) + + super.init( + transitionStyle: .scroll, + navigationOrientation: .horizontal, + options: [.interPageSpacing: CGFloat.defaultSpacing]) + + let index = statusViewModel.attachmentViewModels.firstIndex { + $0.attachment.id == initiallyVisible.attachment.id + } + + setViewControllers([imageViewControllers[index ?? 0]], direction: .forward, animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + dataSource = self + view.backgroundColor = .secondarySystemBackground + view.subviews.compactMap { $0 as? UIScrollView }.first?.bounces = imageViewControllers.count > 1 + + navigationItem.leftBarButtonItem = .init( + systemItem: .close, + primaryAction: UIAction { [weak self] _ in self?.presentingViewController?.dismiss(animated: true) }) + + navigationController?.barHideOnTapGestureRecognizer.addTarget( + self, + action: #selector(toggleDescriptionVisibility)) + } +} + +extension ImagePageViewController { + @objc func toggleDescriptionVisibility() { + for controller in imageViewControllers { + controller.toggleDescriptionVisibility() + } + } +} + +extension ImagePageViewController: UIPageViewControllerDataSource { + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard + let imageViewController = viewController as? ImageViewController, + let index = imageViewControllers.firstIndex(of: imageViewController), + index + 1 < imageViewControllers.count + else { return nil } + + return imageViewControllers[index + 1] + } + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard + let imageViewController = viewController as? ImageViewController, + let index = imageViewControllers.firstIndex(of: imageViewController), + index > 0 + else { return nil } + + return imageViewControllers[index - 1] + } +} diff --git a/View Controllers/ImageViewController.swift b/View Controllers/ImageViewController.swift new file mode 100644 index 0000000..0f6b548 --- /dev/null +++ b/View Controllers/ImageViewController.swift @@ -0,0 +1,169 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Kingfisher +import UIKit +import ViewModels + +class ImageViewController: UIViewController { + private let viewModel: AttachmentViewModel + private let scrollView = UIScrollView() + private let contentView = UIView() + private let imageView = AnimatedImageView() + private let playerView = PlayerView() + private let descriptionBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) + private let descriptionTextView = UITextView() + + init(viewModel: AttachmentViewModel) { + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // swiftlint:disable:next function_body_length + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondarySystemBackground + + view.addSubview(scrollView) + scrollView.delegate = self + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.maximumZoomScale = Self.maximumZoomScale + + contentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentView) + + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + + contentView.addSubview(playerView) + playerView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(descriptionBackgroundView) + descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false + descriptionBackgroundView.isHidden = viewModel.attachment.description == nil + || viewModel.attachment.description == "" + + descriptionBackgroundView.contentView.addSubview(descriptionTextView) + descriptionTextView.translatesAutoresizingMaskIntoConstraints = false + descriptionTextView.backgroundColor = .clear + descriptionTextView.font = .preferredFont(forTextStyle: .caption1) + descriptionTextView.adjustsFontForContentSizeCategory = true + descriptionTextView.text = viewModel.attachment.description + descriptionTextView.isScrollEnabled = false + descriptionTextView.isEditable = false + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor), + contentView.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + imageView.topAnchor.constraint(equalTo: contentView.topAnchor), + imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + playerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + playerView.topAnchor.constraint(equalTo: contentView.topAnchor), + playerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + playerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + descriptionBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + descriptionBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + descriptionBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + descriptionTextView.leadingAnchor.constraint( + equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor), + descriptionTextView.topAnchor.constraint(equalTo: descriptionBackgroundView.topAnchor), + descriptionTextView.trailingAnchor.constraint( + equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor), + descriptionTextView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + + switch viewModel.attachment.type { + case .image: + playerView.isHidden = true + imageView.isHidden = false + imageView.kf.indicatorType = .activity + imageView.kf.setImage( + with: viewModel.attachment.previewUrl, + options: [.onlyFromCache], + completionHandler: { [weak self] in + guard let self = self else { return } + + if case .success = $0 { + self.imageView.kf.indicatorType = .none + } + + self.imageView.kf.setImage( + with: self.viewModel.attachment.url, + options: [.keepCurrentImageWhileLoading]) + }) + case .gifv: + playerView.isHidden = false + imageView.isHidden = true + let player = PlayerCache.shared.player(url: viewModel.attachment.url) + + player.isMuted = true + + playerView.player = player + player.play() + default: break + } + } +} + +extension ImageViewController { + func toggleDescriptionVisibility() { + UIView.animate(withDuration: .shortAnimationDuration) { + self.descriptionBackgroundView.alpha = self.descriptionBackgroundView.alpha > 0 ? 0 : 1 + } + } +} + +extension ImageViewController: UIScrollViewDelegate { + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + contentView + } + + // https://stackoverflow.com/a/40480610/2484482 + func scrollViewDidZoom(_ scrollView: UIScrollView) { + if scrollView.zoomScale > 1, + let contentSize = imageView.image?.size ?? playerView.player?.currentItem?.presentationSize { + let ratio = min(contentView.frame.width / contentSize.width, contentView.frame.height / contentSize.height) + + let newWidth = contentSize.width * ratio + let newHeight = contentSize.height * ratio + + let horizontalInset = 0.5 * (newWidth * scrollView.zoomScale > contentView.frame.width + ? (newWidth - contentView.frame.width) + : (scrollView.frame.width - scrollView.contentSize.width)) + let verticalInset = 0.5 * (newHeight * scrollView.zoomScale > contentView.frame.height + ? (newHeight - contentView.frame.height) + : (scrollView.frame.height - scrollView.contentSize.height)) + + scrollView.contentInset = .init( + top: verticalInset, + left: horizontalInset, + bottom: verticalInset, + right: horizontalInset) + } else { + scrollView.contentInset = .zero + } + } +} + +private extension ImageViewController { + static let maximumZoomScale: CGFloat = 5 +} diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index a93790a..65426ea 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -310,7 +310,12 @@ private extension TableViewController { player.play() } case .image, .gifv: - break + let imagePageViewController = ImagePageViewController( + initiallyVisible: attachmentViewModel, + statusViewModel: statusViewModel) + let imageNavigationController = ImageNavigationController(imagePageViewController: imagePageViewController) + + present(imageNavigationController, animated: true) case .unknown: break } diff --git a/Views/PlayerView.swift b/Views/PlayerView.swift index 75f8180..3f144e7 100644 --- a/Views/PlayerView.swift +++ b/Views/PlayerView.swift @@ -15,8 +15,11 @@ class PlayerView: UIView { override init(frame: CGRect) { super.init(frame: frame) + } - (layer as? AVPlayerLayer)?.videoGravity = .resizeAspectFill + var videoGravity: AVLayerVideoGravity { + get { playerLayer.videoGravity } + set { playerLayer.videoGravity = newValue } } @available(*, unavailable) @@ -24,3 +27,10 @@ class PlayerView: UIView { fatalError("init(coder:) has not been implemented") } } + +private extension PlayerView { + var playerLayer: AVPlayerLayer { + // swiftlint:disable:next force_cast + layer as! AVPlayerLayer + } +} diff --git a/Views/Status/StatusAttachmentView.swift b/Views/Status/StatusAttachmentView.swift index 8911067..10fbb8f 100644 --- a/Views/Status/StatusAttachmentView.swift +++ b/Views/Status/StatusAttachmentView.swift @@ -100,6 +100,7 @@ private extension StatusAttachmentView { addSubview(playerView) playerView.translatesAutoresizingMaskIntoConstraints = false + playerView.videoGravity = .resizeAspectFill playerView.isHidden = true addSubview(button) diff --git a/Views/ViewConstants.swift b/Views/ViewConstants.swift index 24bfce8..2d1d239 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -13,6 +13,7 @@ extension CGFloat { extension TimeInterval { static let defaultAnimationDuration: Self = 0.5 + static let shortAnimationDuration = defaultAnimationDuration / 2 } extension UIImage {