From e6c6293c10f572e43e5a19a24e24ff5695c0f4f8 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 14 Jun 2019 17:23:03 -0700 Subject: [PATCH] Add multi-image gallery Closes #18 --- Tusker.xcodeproj/project.pbxproj | 44 +++++++++ .../UIViewController+Delegates.swift | 22 +++-- .../Attachment/AttachmentViewController.swift | 74 +++++++++++++++ .../Attachment/AttachmentViewController.xib | 24 +++++ .../Gallery/GalleryViewController.swift | 92 +++++++++++++++++++ .../GalleryExpandAnimationController.swift | 83 +++++++++++++++++ .../GalleryShrinkAnimationController.swift | 85 +++++++++++++++++ .../LargeImageViewController.swift | 44 ++++++--- .../Profile/ProfileTableViewController.swift | 6 +- .../Utilities/LoadingViewController.swift | 4 +- .../Utilities/UIViewController+Children.swift | 80 ++++++++++++++-- Tusker/TuskerNavigationDelegate.swift | 36 +++++--- Tusker/Views/AttachmentView.swift | 4 +- Tusker/Views/Status/StatusTableViewCell.swift | 9 +- 14 files changed, 552 insertions(+), 55 deletions(-) create mode 100644 Tusker/Screens/Attachment/AttachmentViewController.swift create mode 100644 Tusker/Screens/Attachment/AttachmentViewController.xib create mode 100644 Tusker/Screens/Gallery/GalleryViewController.swift create mode 100644 Tusker/Screens/Gallery/Transitions/GalleryExpandAnimationController.swift create mode 100644 Tusker/Screens/Gallery/Transitions/GalleryShrinkAnimationController.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index c6ccbfb6ce..076b193aeb 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -7,10 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 0411610022B442870030A9B7 /* AttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041160FE22B442870030A9B7 /* AttachmentViewController.swift */; }; + 0411610122B442870030A9B7 /* AttachmentViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 041160FF22B442870030A9B7 /* AttachmentViewController.xib */; }; 04496BD721625361001F1B23 /* ContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04496BD621625361001F1B23 /* ContentLabel.swift */; }; 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450531E22B0097E00100BA2 /* Timline+UI.swift */; }; + 0454DDAF22B462EF00B8BB8E /* GalleryExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0454DDAE22B462EF00B8BB8E /* GalleryExpandAnimationController.swift */; }; + 0454DDB122B467AA00B8BB8E /* GalleryShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0454DDB022B467AA00B8BB8E /* GalleryShrinkAnimationController.swift */; }; 0461A3902163CBAE00C0A807 /* Cache.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0461A38F2163CBAE00C0A807 /* Cache.framework */; }; 0461A3912163CBAE00C0A807 /* Cache.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0461A38F2163CBAE00C0A807 /* Cache.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04D14BAE22B34A2800642648 /* GalleryViewController.swift */; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; @@ -235,9 +240,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 041160FE22B442870030A9B7 /* AttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentViewController.swift; sourceTree = ""; }; + 041160FF22B442870030A9B7 /* AttachmentViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AttachmentViewController.xib; sourceTree = ""; }; 04496BD621625361001F1B23 /* ContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabel.swift; sourceTree = ""; }; 0450531E22B0097E00100BA2 /* Timline+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timline+UI.swift"; sourceTree = ""; }; + 0454DDAE22B462EF00B8BB8E /* GalleryExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryExpandAnimationController.swift; sourceTree = ""; }; + 0454DDB022B467AA00B8BB8E /* GalleryShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryShrinkAnimationController.swift; sourceTree = ""; }; 0461A38F2163CBAE00C0A807 /* Cache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Cache.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 04D14BAE22B34A2800642648 /* GalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryViewController.swift; sourceTree = ""; }; 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = ""; }; 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = ""; }; @@ -455,6 +465,33 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0411610422B4571E0030A9B7 /* Attachment */ = { + isa = PBXGroup; + children = ( + 041160FE22B442870030A9B7 /* AttachmentViewController.swift */, + 041160FF22B442870030A9B7 /* AttachmentViewController.xib */, + ); + path = Attachment; + sourceTree = ""; + }; + 0411610522B457290030A9B7 /* Gallery */ = { + isa = PBXGroup; + children = ( + 0411610622B457360030A9B7 /* Transitions */, + 04D14BAE22B34A2800642648 /* GalleryViewController.swift */, + ); + path = Gallery; + sourceTree = ""; + }; + 0411610622B457360030A9B7 /* Transitions */ = { + isa = PBXGroup; + children = ( + 0454DDAE22B462EF00B8BB8E /* GalleryExpandAnimationController.swift */, + 0454DDB022B467AA00B8BB8E /* GalleryShrinkAnimationController.swift */, + ); + path = Transitions; + sourceTree = ""; + }; D60A548C21ED515800F1F87C /* GMImagePicker */ = { isa = PBXGroup; children = ( @@ -617,6 +654,8 @@ D641C786213DD852004B4513 /* Notifications */, D641C787213DD862004B4513 /* Compose */, D641C788213DD86D004B4513 /* Large Image */, + 0411610422B4571E0030A9B7 /* Attachment */, + 0411610522B457290030A9B7 /* Gallery */, D641C789213DD87E004B4513 /* Preferences */, ); path = Screens; @@ -1261,6 +1300,7 @@ D663626621360DD700C9CBA2 /* Preferences.storyboard in Resources */, D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */, D67C57B221E28FAD00C3118B /* ComposeStatusReplyView.xib in Resources */, + 0411610122B442870030A9B7 /* AttachmentViewController.xib in Resources */, D60C07E421E8176B0057FAA8 /* ComposeMediaView.xib in Resources */, D667E5E12134937B0057A976 /* StatusTableViewCell.xib in Resources */, D6A5FAF1217B7E05003DB2D9 /* ComposeViewController.xib in Resources */, @@ -1357,10 +1397,12 @@ 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */, D6C693F92162E4DB007D6A6D /* StatusContentLabel.swift in Sources */, D6D58DF922074B74009C8DD9 /* LinkLabel.swift in Sources */, + 0454DDAF22B462EF00B8BB8E /* GalleryExpandAnimationController.swift in Sources */, D6285B5121EA6E6E00FE4B39 /* AdvancedTableViewController.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */, + 0411610022B442870030A9B7 /* AttachmentViewController.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */, D627FF81217FE8F400CC0648 /* BehaviorTableViewController.swift in Sources */, @@ -1369,6 +1411,7 @@ D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */, + 0454DDB122B467AA00B8BB8E /* GalleryShrinkAnimationController.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, @@ -1409,6 +1452,7 @@ D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */, D663626A2136163000C9CBA2 /* PreferencesAdaptive.swift in Sources */, D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */, + 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, D641C77D213CB024004B4513 /* FollowNotificationTableViewCell.swift in Sources */, D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */, D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */, diff --git a/Tusker/Extensions/UIViewController+Delegates.swift b/Tusker/Extensions/UIViewController+Delegates.swift index eeec990e23..25df153d12 100644 --- a/Tusker/Extensions/UIViewController+Delegates.swift +++ b/Tusker/Extensions/UIViewController+Delegates.swift @@ -12,27 +12,31 @@ extension UIViewController: UIViewControllerTransitioningDelegate { public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { if presented is LargeImageViewController { return LargeImageExpandAnimationController() - } else { - return nil + } else if presented is GalleryViewController { + return GalleryExpandAnimationController() } + return nil } - + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { if let dismissed = dismissed as? LargeImageViewController { return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController) - } else { - return nil + } else if let dismissed = dismissed as? GalleryViewController { + return GalleryShrinkAnimationController(interactionController: dismissed.dismissInteractionController) } + return nil } - + public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { if let animator = animator as? LargeImageShrinkAnimationController, let interactionController = animator.interactionController, interactionController.inProgress { - return interactionController - } else { - return nil + } else if let animator = animator as? GalleryShrinkAnimationController, + let interactionController = animator.interactionController, + interactionController.inProgress { + return interactionController } + return nil } } diff --git a/Tusker/Screens/Attachment/AttachmentViewController.swift b/Tusker/Screens/Attachment/AttachmentViewController.swift new file mode 100644 index 0000000000..c2a8c6cc38 --- /dev/null +++ b/Tusker/Screens/Attachment/AttachmentViewController.swift @@ -0,0 +1,74 @@ +// AttachmentViewController.swift +// Tusker +// +// Created by Shadowfacts on 6/14/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class AttachmentViewController: UIViewController { + + let attachment: Attachment + + var largeImageVC: LargeImageViewController? + var loadingVC: LoadingViewController? + + private var initialControlsVisible: Bool = true + var controlsVisible: Bool { + get { + return largeImageVC?.controlsVisible ?? initialControlsVisible + } + set { + if let largeImageVC = largeImageVC { + largeImageVC.setControlsVisible(newValue, animated: false) + } else { + initialControlsVisible = newValue + } + } + } + + override var childForHomeIndicatorAutoHidden: UIViewController? { + return largeImageVC + } + + init(attachment: Attachment) { + self.attachment = attachment + + super.init(nibName: "AttachmentViewController", bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + if let data = ImageCache.attachments.get(attachment.url) { + createLargeImage(data: data) + } else { + loadingVC = LoadingViewController() + embedChild(loadingVC!) + ImageCache.attachments.get(attachment.url) { [weak self] (data) in + DispatchQueue.main.async { + self?.loadingVC?.removeViewAndController() + self?.createLargeImage(data: data!) + } + } + } + } + + func createLargeImage(data: Data) { + guard let image = UIImage(data: data) else { return } + largeImageVC = LargeImageViewController(image: image, description: attachment.description, sourceFrame: nil, sourceCornerRadius: nil) + largeImageVC!.initialControlsVisible = initialControlsVisible + largeImageVC!.shrinkGestureEnabled = false + if attachment.url.pathExtension == "gif" { + largeImageVC!.gifData = data + } + embedChild(largeImageVC!) + } + +} diff --git a/Tusker/Screens/Attachment/AttachmentViewController.xib b/Tusker/Screens/Attachment/AttachmentViewController.xib new file mode 100644 index 0000000000..2a90aae243 --- /dev/null +++ b/Tusker/Screens/Attachment/AttachmentViewController.xib @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/Screens/Gallery/GalleryViewController.swift b/Tusker/Screens/Gallery/GalleryViewController.swift new file mode 100644 index 0000000000..6ad0a47af5 --- /dev/null +++ b/Tusker/Screens/Gallery/GalleryViewController.swift @@ -0,0 +1,92 @@ +// GalleryViewController.swift +// Tusker +// +// Created by Shadowfacts on 6/13/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class GalleryViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + + var dismissInteractionController: LargeImageInteractionController? + + let attachments: [Attachment] + let sourcesInfo: [(CGRect, CGFloat)] + let startIndex: Int + + let pages: [AttachmentViewController] + + var currentIndex: Int { + guard let vc = viewControllers?.first as? AttachmentViewController, + let index = pages.firstIndex(of: vc) else { + fatalError() + } + return index + } + + override var prefersStatusBarHidden: Bool { + return true + } + override var childForHomeIndicatorAutoHidden: UIViewController? { + return + viewControllers?.first + } + + init(attachments: [Attachment], sourcesInfo: [(CGRect, CGFloat)], startIndex: Int) { + self.attachments = attachments + self.sourcesInfo = sourcesInfo + self.startIndex = startIndex + + self.pages = attachments.map(AttachmentViewController.init) + + super.init(transitionStyle: .scroll, navigationOrientation: .horizontal) + + setViewControllers([pages[startIndex]], direction: .forward, animated: false) + + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.dataSource = self + self.delegate = self + + dismissInteractionController = LargeImageInteractionController(viewController: self) + } + + + // MARK: - Page View Controller Data Source + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let attachment = viewController as? AttachmentViewController, + let index = pages.firstIndex(of: attachment), + index > 0 else { + return nil + } + return pages[index - 1] + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let attachment = viewController as? AttachmentViewController, + let index = pages.firstIndex(of: attachment), + index < pages.count - 1 else { + return nil + } + return pages[index + 1] + } + + // MARK: - Page View Controller Delegate + func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + let pending = pendingViewControllers.first as! AttachmentViewController + let current = viewControllers!.first as! AttachmentViewController + pending.controlsVisible = current.controlsVisible + } + +} diff --git a/Tusker/Screens/Gallery/Transitions/GalleryExpandAnimationController.swift b/Tusker/Screens/Gallery/Transitions/GalleryExpandAnimationController.swift new file mode 100644 index 0000000000..809957a289 --- /dev/null +++ b/Tusker/Screens/Gallery/Transitions/GalleryExpandAnimationController.swift @@ -0,0 +1,83 @@ +// GalleryExpandAnimationController.swift +// Tusker +// +// Created by Shadowfacts on 6/14/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Gifu + +class GalleryExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.2 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let fromVC = transitionContext.viewController(forKey: .from), + let toVC = transitionContext.viewController(forKey: .to) as? GalleryViewController else { + return + } + let attachment = toVC.attachments[toVC.startIndex] + let (sourceFrame, sourceCornerRadius) = toVC.sourcesInfo[toVC.startIndex] + + let finalVCFrame = transitionContext.finalFrame(for: toVC) + + guard let data = ImageCache.attachments.get(attachment.url), let image = UIImage(data: data) else { + toVC.view.frame = finalVCFrame + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + return + } + + let ratio = image.size.width / image.size.height + var width = finalVCFrame.width + var height = width / ratio + let maxHeight = fromVC.view.bounds.height - fromVC.view.safeAreaInsets.top - fromVC.view.safeAreaInsets.bottom + if height > maxHeight { + let scaleFactor = maxHeight / height + width *= scaleFactor + height = maxHeight + } + let finalFrame = CGRect(x: finalVCFrame.midX - width / 2, y: finalVCFrame.midY - height / 2, width: width, height: height) + + let containerView = transitionContext.containerView + + let imageView = GIFImageView(frame: sourceFrame) + imageView.image = image + if attachment.url.pathExtension == "gif" { + imageView.animate(withGIFData: data) + } + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = sourceCornerRadius + imageView.layer.masksToBounds = true + + let blackView = UIView(frame: finalVCFrame) + blackView.backgroundColor = .black + blackView.alpha = 0 + + containerView.addSubview(toVC.view) + containerView.addSubview(blackView) + containerView.addSubview(imageView) + + toVC.view.isHidden = true + + let duration = transitionDuration(using: transitionContext) + UIView.animate(withDuration: duration, animations: { + imageView.frame = finalFrame + imageView.layer.cornerRadius = 0 + blackView.alpha = 1 + }, completion: { _ in + toVC.view.frame = finalVCFrame + + toVC.view.isHidden = false + fromVC.view.isHidden = false + blackView.removeFromSuperview() + imageView.removeFromSuperview() + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + }) + + } + +} diff --git a/Tusker/Screens/Gallery/Transitions/GalleryShrinkAnimationController.swift b/Tusker/Screens/Gallery/Transitions/GalleryShrinkAnimationController.swift new file mode 100644 index 0000000000..8b1e76ece6 --- /dev/null +++ b/Tusker/Screens/Gallery/Transitions/GalleryShrinkAnimationController.swift @@ -0,0 +1,85 @@ +// GalleryShrinkAnimationController.swift +// Tusker +// +// Created by Shadowfacts on 6/14/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Gifu + +class GalleryShrinkAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + + let interactionController: LargeImageInteractionController? + + init(interactionController: LargeImageInteractionController?) { + self.interactionController = interactionController + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.2 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let fromVC = transitionContext.viewController(forKey: .from) as? GalleryViewController, + let toVC = transitionContext.viewController(forKey: .to) else { + return + } + + let (sourceFrame, sourceCornerRadius) = fromVC.sourcesInfo[fromVC.currentIndex] + let originalVCFrame = fromVC.view.frame + + let attachment = fromVC.attachments[fromVC.currentIndex] + + guard let data = ImageCache.attachments.get(attachment.url), + let image = UIImage(data: data) else { + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + return + } + + let ratio = image.size.width / image.size.height + var width = originalVCFrame.width + var height = width / ratio + let maxHeight = fromVC.view.bounds.height - fromVC.view.safeAreaInsets.top - fromVC.view.safeAreaInsets.bottom + if height > maxHeight { + let scaleFactor = maxHeight / height + width *= scaleFactor + height = maxHeight + } + let originalFrame = CGRect(x: originalVCFrame.midX - width / 2, y: originalVCFrame.midY - height / 2, width: width, height: height) + + let imageView = GIFImageView(frame: originalFrame) + imageView.image = image + if attachment.url.pathExtension == "gif" { + imageView.animate(withGIFData: data) + } + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 0 + imageView.layer.masksToBounds = true + + let blackView = UIView(frame: originalVCFrame) + blackView.backgroundColor = .black + blackView.alpha = 1 + + let containerView = transitionContext.containerView + containerView.addSubview(toVC.view) + containerView.addSubview(blackView) + containerView.addSubview(imageView) + + let duration = transitionDuration(using: transitionContext) + UIView.animate(withDuration: duration, animations: { + imageView.frame = sourceFrame + imageView.layer.cornerRadius = sourceCornerRadius + blackView.alpha = 0 + }, completion: { _ in + blackView.removeFromSuperview() + imageView.removeFromSuperview() + + if transitionContext.transitionWasCancelled { + toVC.view.removeFromSuperview() + } + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + }) + } +} diff --git a/Tusker/Screens/Large Image/LargeImageViewController.swift b/Tusker/Screens/Large Image/LargeImageViewController.swift index ee047492c1..5bb643d9f3 100644 --- a/Tusker/Screens/Large Image/LargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LargeImageViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import Pachyderm import Photos import Gifu @@ -41,20 +42,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate { var gifData: Data? var imageDescription: String? - var controlsVisible = true { + var initialControlsVisible = true + private(set) var controlsVisible = true { didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() - - UIView.animate(withDuration: 0.2) { - let topOffset = self.controlsVisible ? 0 : -self.topControlsView.bounds.height - self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset) - if self.imageDescription != nil { - let bottomOffset = self.controlsVisible ? 0 : self.bottomControlsView.bounds.height + self.view.safeAreaInsets.bottom - self.bottomControlsView.transform = CGAffineTransform(translationX: 0, y: bottomOffset) - } - } } } + var shrinkGestureEnabled = true var prevZoomScale: CGFloat? @@ -73,6 +67,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate { self.originCornerRadius = sourceCornerRadius super.init(nibName: "LargeImageViewController", bundle: nil) + + modalPresentationStyle = .fullScreen } // init(gifData: Data?, description: String?, sourceFrame: CGRect, sourceCornerRadius: CGFloat, router: AppRouter) { @@ -92,6 +88,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate { override func viewDidLoad() { super.viewDidLoad() + setControlsVisible(initialControlsVisible, animated: false) + imageView.image = image if let gifData = gifData { imageView.animate(withGIFData: gifData) @@ -106,7 +104,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate { bottomControlsView.isHidden = true } - dismissInteractionController = LargeImageInteractionController(viewController: self) + if shrinkGestureEnabled { + dismissInteractionController = LargeImageInteractionController(viewController: self) + } view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:)))) let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:))) @@ -142,6 +142,26 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate { } } } + + func setControlsVisible(_ controlsVisible: Bool, animated: Bool) { + self.controlsVisible = controlsVisible + if animated { + UIView.animate(withDuration: 0.2) { + self.updateControlsView() + } + } else { + updateControlsView() + } + } + + func updateControlsView() { + let topOffset = self.controlsVisible ? 0 : -self.topControlsView.bounds.height + self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset) + if self.imageDescription != nil { + let bottomOffset = self.controlsVisible ? 0 : self.bottomControlsView.bounds.height + self.view.safeAreaInsets.bottom + self.bottomControlsView.transform = CGAffineTransform(translationX: 0, y: bottomOffset) + } + } func viewForZooming(in scrollView: UIScrollView) -> UIView? { return imageView @@ -190,7 +210,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate { if scrollView.zoomScale > scrollView.minimumZoomScale { animateZoomOut() } else { - controlsVisible = !controlsVisible + setControlsVisible(!controlsVisible, animated: true) } } diff --git a/Tusker/Screens/Profile/ProfileTableViewController.swift b/Tusker/Screens/Profile/ProfileTableViewController.swift index efd47b2881..fb8176f4d7 100644 --- a/Tusker/Screens/Profile/ProfileTableViewController.swift +++ b/Tusker/Screens/Profile/ProfileTableViewController.swift @@ -66,7 +66,7 @@ class ProfileTableViewController: EnhancedTableViewController, PreferencesAdapti updateAccountUI() } else { loadingVC = LoadingViewController() - add(loadingVC!) + embedChild(loadingVC!) MastodonCache.account(for: accountID) { (account) in guard account != nil else { let alert = UIAlertController(title: "Something Went Wrong", message: "Couldn't load the selected account", preferredStyle: .alert) @@ -86,7 +86,7 @@ class ProfileTableViewController: EnhancedTableViewController, PreferencesAdapti } } else { loadingVC = LoadingViewController() - add(loadingVC!) + embedChild(loadingVC!) shouldLoadOnAccountIDSet = true } } @@ -127,7 +127,7 @@ class ProfileTableViewController: EnhancedTableViewController, PreferencesAdapti } func updateAccountUI() { - loadingVC?.remove() + loadingVC?.removeViewAndController() updateUIForPreferences() diff --git a/Tusker/Screens/Utilities/LoadingViewController.swift b/Tusker/Screens/Utilities/LoadingViewController.swift index d5a79211b1..12049260a6 100644 --- a/Tusker/Screens/Utilities/LoadingViewController.swift +++ b/Tusker/Screens/Utilities/LoadingViewController.swift @@ -16,8 +16,10 @@ class LoadingViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .systemBackground + activityIndicator = UIActivityIndicatorView(style: .large) - activityIndicator.color = .darkGray + activityIndicator.color = .secondaryLabel activityIndicator.translatesAutoresizingMaskIntoConstraints = false view.addSubview(activityIndicator) diff --git a/Tusker/Screens/Utilities/UIViewController+Children.swift b/Tusker/Screens/Utilities/UIViewController+Children.swift index 743e2349fb..4221a950de 100644 --- a/Tusker/Screens/Utilities/UIViewController+Children.swift +++ b/Tusker/Screens/Utilities/UIViewController+Children.swift @@ -8,18 +8,80 @@ import UIKit +// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIViewController.swift extension UIViewController { - func add(_ child: UIViewController) { - addChild(child) - view.addSubview(child.view) - child.didMove(toParent: self) + func embedChild(_ newChild: UIViewController, in container: UIView? = nil) { + // if the view controller is already a child of something else, remove it + if let oldParent = newChild.parent, oldParent != self { + newChild.beginAppearanceTransition(false, animated: false) + newChild.willMove(toParent: nil) + newChild.removeFromParent() + + if newChild.viewIfLoaded?.superview != nil { + newChild.viewIfLoaded?.removeFromSuperview() + } + + newChild.endAppearanceTransition() + } + + // since .view returns an IUO, by default the type of this is "UIView?" + // explicitly type the variable because We Know Better™ + var targetContainer: UIView = container ?? self.view + if !targetContainer.isContainedWithin(view) { + targetContainer = view + } + + // add the view controller as a child + if newChild.parent != self { + newChild.beginAppearanceTransition(true, animated: false) + addChild(newChild) + newChild.didMove(toParent: self) + targetContainer.embedSubview(newChild.view) + newChild.endAppearanceTransition() + } else { + // the view controller is already a child + // make sure it's in the right view + + // we don't do the appearance transition stuff here, + // because the vc is already a child, so *presumably* + // that transition stuff has already appened + targetContainer.embedSubview(newChild.view) + } } - func remove() { - guard parent != nil else { return } - - willMove(toParent: nil) - removeFromParent() + func removeViewAndController() { view.removeFromSuperview() + removeFromParent() } } + +// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIView.swift +extension UIView { + func embedSubview(_ subview: UIView) { + if subview.superview == self { return } + + if subview.superview != nil { + subview.removeFromSuperview() + } + + subview.frame = bounds + addSubview(subview) + + NSLayoutConstraint.activate([ + subview.leadingAnchor.constraint(equalTo: leadingAnchor), + subview.trailingAnchor.constraint(equalTo: trailingAnchor), + subview.topAnchor.constraint(equalTo: topAnchor), + subview.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + func isContainedWithin(_ other: UIView) -> Bool { + var current: UIView? = self + while let proposedView = current { + if proposedView == other { return true } + current = proposedView.superview + } + return false + } +} + diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index e891d4496d..8789933b53 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -34,6 +34,10 @@ protocol TuskerNavigationDelegate { func showLargeImage(gifData: Data, description: String?, animatingFrom sourceView: UIView) + func gallery(attachments: [Attachment], sourceViews: [UIView], startIndex: Int) -> GalleryViewController + + func showGallery(attachments: [Attachment], sourceViews: [UIView], startIndex: Int) + func showMoreOptions(forStatus statusID: String) func showMoreOptions(forURL url: URL) @@ -95,7 +99,7 @@ extension TuskerNavigationDelegate where Self: UIViewController { present(vc, animated: true) } - func largeImage(_ image: UIImage, description: String?, sourceView: UIView) -> LargeImageViewController { + private func sourceViewInfo(_ sourceView: UIView) -> (CGRect, CGFloat) { var sourceFrame = sourceView.convert(sourceView.bounds, to: view) if let scrollView = view as? UIScrollView { let scale = scrollView.zoomScale @@ -105,28 +109,21 @@ extension TuskerNavigationDelegate where Self: UIViewController { let y = sourceFrame.minY * scale - scrollView.contentOffset.y + scrollView.frame.minY sourceFrame = CGRect(x: x, y: y, width: width, height: height) } - let sourceCornerRadius = sourceView.layer.cornerRadius + return (sourceFrame, sourceView.layer.cornerRadius) + } + + func largeImage(_ image: UIImage, description: String?, sourceView: UIView) -> LargeImageViewController { + let (sourceFrame, sourceCornerRadius) = sourceViewInfo(sourceView) let vc = LargeImageViewController(image: image, description: description, sourceFrame: sourceFrame, sourceCornerRadius: sourceCornerRadius) vc.transitioningDelegate = self - vc.modalPresentationStyle = .fullScreen return vc } func largeImage(gifData: Data, description: String?, sourceView: UIView) -> LargeImageViewController { - var sourceFrame = sourceView.convert(sourceView.bounds, to: view) - if let scrollView = view as? UIScrollView { - let scale = scrollView.zoomScale - let width = sourceFrame.width * scale - let height = sourceFrame.height * scale - let x = sourceFrame.minX * scale - scrollView.contentOffset.x + scrollView.frame.minX - let y = sourceFrame.minY * scale - scrollView.contentOffset.y + scrollView.frame.minY - sourceFrame = CGRect(x: x, y: y, width: width, height: height) - } - let sourceCornerRadius = sourceView.layer.cornerRadius + let (sourceFrame, sourceCornerRadius) = sourceViewInfo(sourceView) let vc = LargeImageViewController(image: UIImage(data: gifData)!, description: description, sourceFrame: sourceFrame, sourceCornerRadius: sourceCornerRadius) vc.transitioningDelegate = self vc.gifData = gifData - vc.modalPresentationStyle = .fullScreen return vc } @@ -138,6 +135,17 @@ extension TuskerNavigationDelegate where Self: UIViewController { present(largeImage(gifData: gifData, description: description, sourceView: sourceView), animated: true) } + func gallery(attachments: [Attachment], sourceViews: [UIView], startIndex: Int) -> GalleryViewController { + let sourcesInfo = sourceViews.map(sourceViewInfo) + let vc = GalleryViewController(attachments: attachments, sourcesInfo: sourcesInfo, startIndex: startIndex) + vc.transitioningDelegate = self + return vc + } + + func showGallery(attachments: [Attachment], sourceViews: [UIView], startIndex: Int) { + present(gallery(attachments: attachments, sourceViews: sourceViews, startIndex: startIndex), animated: true) + } + private func moreOptions(forURL url: URL) -> UIAlertController { let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) alert.title = url.absoluteString diff --git a/Tusker/Views/AttachmentView.swift b/Tusker/Views/AttachmentView.swift index 24c38cbae6..f884112c03 100644 --- a/Tusker/Views/AttachmentView.swift +++ b/Tusker/Views/AttachmentView.swift @@ -65,9 +65,7 @@ class AttachmentView: UIImageView, GIFAnimatable { } @objc func imagePressed() { - if image != nil { - delegate?.showLargeAttachment(for: self) - } + delegate?.showLargeAttachment(for: self) } } diff --git a/Tusker/Views/Status/StatusTableViewCell.swift b/Tusker/Views/Status/StatusTableViewCell.swift index 59357f34fb..b73dd43e4d 100644 --- a/Tusker/Views/Status/StatusTableViewCell.swift +++ b/Tusker/Views/Status/StatusTableViewCell.swift @@ -340,11 +340,12 @@ extension StatusTableViewCell: TableViewSwipeActionProvider { extension StatusTableViewCell: AttachmentViewDelegate { func showLargeAttachment(for attachmentView: AttachmentView) { - if let gifData = attachmentView.gifData { - delegate?.showLargeImage(gifData: gifData, description: attachmentView.attachment.description, animatingFrom: attachmentView) - } else { - delegate?.showLargeImage(attachmentView.image!, description: attachmentView.attachment.description, animatingFrom: attachmentView) + guard let status = MastodonCache.status(for: statusID) else { fatalError("Missing cached status \(statusID!)") } + let startIndex = status.attachments.firstIndex { $0.id == attachmentView.attachment.id } ?? 0 + let sourceViews = status.attachments.map { attachment in + attachmentsView.subviews.first { ($0 as! AttachmentView).attachment.id == attachment.id }! } + delegate?.showGallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: startIndex) } }