From 985af76d0b53aff5419109db03698a8ec3bfbf32 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 15 Mar 2018 13:46:29 -0400 Subject: [PATCH] WIP: All Media view TODO - [ ] label video/gif - [ ] reasonable load perf - [ ] reasonable scroll perf - [ ] select / delete - [ ] cancel share action from media details returns signal style NICE TO HAVE - [ ] fancy in/out animation from All Media <-> tiles - [ ] label video thumbnail with duration stamp - [ ] Other perf? - [ ] dbModified? - [ ] select / send - [ ] darken section header a shade once it's "active" DONE - [x] tap to refocus on new media - [x] generate test data - [x] section headers - [x] equal spacing around cells // FREEBIE WIP WIP extract datasouce to GalleryViewController - [x] swipe through is broken - [x] present animation - [x] dismiss animation // FREEBIE --- Signal.xcodeproj/project.pbxproj | 16 +- .../ConversationViewController.m | 13 +- .../MediaGalleryViewController.swift | 512 ++++++++++++++++++ .../MediaPageViewController.swift | 482 ++++------------- .../MediaTileViewController.swift | 303 +++++++++++ .../MessageDetailViewController.swift | 6 +- .../OWSConversationSettingsViewController.m | 14 + .../translations/en.lproj/Localizable.strings | 6 + SignalMessaging/categories/Sequence+OWS.swift | 29 + SignalMessaging/views/CommonStrings.swift | 4 + .../Messages/Attachments/TSAttachmentStream.m | 6 +- 11 files changed, 1001 insertions(+), 390 deletions(-) create mode 100644 Signal/src/ViewControllers/MediaGalleryViewController.swift create mode 100644 Signal/src/ViewControllers/MediaTileViewController.swift create mode 100644 SignalMessaging/categories/Sequence+OWS.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index dfc804291..7dac7e368 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -271,6 +271,7 @@ 452C7CA72037628B003D51A5 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F170D51E315310003FC1F2 /* Weak.swift */; }; 452D1EE81DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */; }; 452EA09E1EA7ABE00078744B /* AttachmentPointerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */; }; + 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */; }; 452ECA4D1E087E7200E2F016 /* MessageFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */; }; 4535186B1FC635DD00210559 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4535186A1FC635DD00210559 /* ShareViewController.swift */; }; 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4535186C1FC635DD00210559 /* MainInterface.storyboard */; }; @@ -283,11 +284,13 @@ 45360B911F952AA900FA666C /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */; }; 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4539B5851F79348F007141FF /* PushRegistrationManager.swift */; }; 45464DBC1DFA041F001D3FD6 /* DataChannelMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */; }; + 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A84032059C787008B8C75 /* MediaTileViewController.swift */; }; 454A965A1FD6017E008D2A0E /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D913491F62D4A500722898 /* SignalAttachment.swift */; }; 454A965B1FD601BF008D2A0E /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CA1C281F7164F700E51C51 /* MediaMessageView.swift */; }; 454A965F1FD60EA3008D2A0E /* OWSFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A965E1FD60EA2008D2A0E /* OWSFlatButton.swift */; }; 454EBAB41F2BE14C00ACE0BB /* OWSAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D99C911F2937CC00D284D6 /* OWSAnalytics.swift */; }; 4551DB5A205C562300C8AE75 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4551DB59205C562300C8AE75 /* Collection+OWS.swift */; }; + 4551DB5E205C692A00C8AE75 /* Sequence+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4551DB5D205C692A00C8AE75 /* Sequence+OWS.swift */; }; 4556FA681F54AA9500AF40DD /* DebugUIProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4556FA671F54AA9500AF40DD /* DebugUIProfile.swift */; }; 455A16DD1F1FEA0000F86704 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 455A16DB1F1FEA0000F86704 /* Metal.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 455A16DE1F1FEA0000F86704 /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 455A16DC1F1FEA0000F86704 /* MetalKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; @@ -852,6 +855,7 @@ 452C468E1E427E200087B011 /* OutboundCallInitiator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutboundCallInitiator.swift; sourceTree = ""; }; 452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MesssagesBubblesSizeCalculatorTest.swift; path = Models/MesssagesBubblesSizeCalculatorTest.swift; sourceTree = ""; }; 452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPointerView.swift; sourceTree = ""; }; + 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewController.swift; sourceTree = ""; }; 452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageFetcherJob.swift; sourceTree = ""; }; 453034AA200289F50018945D /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; 453518681FC635DD00210559 /* SignalShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SignalShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -866,9 +870,11 @@ 4539B5851F79348F007141FF /* PushRegistrationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushRegistrationManager.swift; sourceTree = ""; }; 453CC0361D08E1A60040EBA3 /* sn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sn; path = translations/sn.lproj/Localizable.strings; sourceTree = ""; }; 45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataChannelMessage.swift; sourceTree = ""; }; + 454A84032059C787008B8C75 /* MediaTileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTileViewController.swift; sourceTree = ""; }; 454A965E1FD60EA2008D2A0E /* OWSFlatButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OWSFlatButton.swift; path = SignalMessaging/Views/OWSFlatButton.swift; sourceTree = SOURCE_ROOT; }; 454B35071D08EED80026D658 /* mk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mk; path = translations/mk.lproj/Localizable.strings; sourceTree = ""; }; 4551DB59205C562300C8AE75 /* Collection+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+OWS.swift"; sourceTree = ""; }; + 4551DB5D205C692A00C8AE75 /* Sequence+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sequence+OWS.swift"; sourceTree = ""; }; 4556FA671F54AA9500AF40DD /* DebugUIProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUIProfile.swift; sourceTree = ""; }; 455A16DB1F1FEA0000F86704 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; 455A16DC1F1FEA0000F86704 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; @@ -1363,6 +1369,7 @@ 34480B5C1FD0A98800BC14EF /* categories */ = { isa = PBXGroup; children = ( + 4551DB59205C562300C8AE75 /* Collection+OWS.swift */, 346129C51FD2072D00532771 /* NSAttributedString+OWS.h */, 346129C11FD2072D00532771 /* NSAttributedString+OWS.m */, 346129C01FD2072C00532771 /* NSString+OWS.h */, @@ -1378,7 +1385,7 @@ 34480B601FD0A98800BC14EF /* UIView+OWS.m */, 346129D41FD20ADC00532771 /* UIViewController+OWS.h */, 346129D31FD20ADB00532771 /* UIViewController+OWS.m */, - 4551DB59205C562300C8AE75 /* Collection+OWS.swift */, + 4551DB5D205C692A00C8AE75 /* Sequence+OWS.swift */, ); path = categories; sourceTree = ""; @@ -1543,9 +1550,11 @@ 34B3F8491E8DF1700035BE1A /* InboxTableViewCell.h */, 34B3F84A1E8DF1700035BE1A /* InboxTableViewCell.m */, 34B3F84C1E8DF1700035BE1A /* InviteFlow.swift */, - 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */, 45B9EE9A200E91FB005D2F2D /* MediaDetailViewController.h */, 45B9EE9B200E91FB005D2F2D /* MediaDetailViewController.m */, + 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */, + 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */, + 454A84032059C787008B8C75 /* MediaTileViewController.swift */, 34CA1C261F7156F300E51C51 /* MessageDetailViewController.swift */, 34B3F84F1E8DF1700035BE1A /* NewContactThreadViewController.h */, 34B3F8501E8DF1700035BE1A /* NewContactThreadViewController.m */, @@ -3072,6 +3081,7 @@ 3461293C1FD1D46A00532771 /* OWSMath.m in Sources */, 451F8A391FD711D6005CB9DA /* ContactsViewHelper.m in Sources */, 346129AF1FD1F5D900532771 /* SystemContactsFetcher.swift in Sources */, + 4551DB5E205C692A00C8AE75 /* Sequence+OWS.swift in Sources */, 344F248B20069F0600CFB4F4 /* ViewControllerUtils.m in Sources */, 451F8A411FD714B8005CB9DA /* ContactTableViewCell.m in Sources */, 346129C81FD2072E00532771 /* NSAttributedString+OWS.m in Sources */, @@ -3084,6 +3094,7 @@ files = ( 3461293E1FD1D72B00532771 /* ExperienceUpgradeFinder.swift in Sources */, 34D1F0BD1F8D108C0066283D /* AttachmentUploadView.m in Sources */, + 452EC6DF205E9E30000E787C /* MediaGalleryViewController.swift in Sources */, 34D1F0BA1F8800D90066283D /* OWSAudioMessageView.m in Sources */, 34D8C02B1ED3685800188D7C /* DebugUIContacts.m in Sources */, 45C9DEB81DF4E35A0065CA84 /* WebRTCCallMessageHandler.swift in Sources */, @@ -3093,6 +3104,7 @@ 341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */, 340FC8AF204DAC8D007AEB0F /* OWSLinkDeviceViewController.m in Sources */, 34E3EF0D1EFC235B007F6822 /* DebugUIDiskUsage.m in Sources */, + 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */, 340FC8B4204DAC8D007AEB0F /* OWSBackupSettingsViewController.m in Sources */, 34D1F0871F8678AA0066283D /* ConversationViewItem.m in Sources */, B6DA6B071B8A2F9A00CA6F98 /* AppStoreRating.m in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index ffb07204e..28795d3c6 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -2033,10 +2033,10 @@ typedef enum : NSUInteger { } TSMessage *mediaMessage = (TSMessage *)viewItem.interaction; - MediaPageViewController *vc = - [[MediaPageViewController alloc] initWithThread:self.thread mediaMessage:mediaMessage]; + MediaGalleryViewController *vc = + [[MediaGalleryViewController alloc] initWithThread:self.thread mediaMessage:mediaMessage]; - [vc presentFromViewController:self replacingView:imageView]; + [vc presentDetailViewFromViewController:self replacingView:imageView]; } - (void)didTapVideoViewItem:(ConversationViewItem *)viewItem @@ -2055,9 +2055,10 @@ typedef enum : NSUInteger { } TSMessage *mediaMessage = (TSMessage *)viewItem.interaction; - MediaPageViewController *vc = - [[MediaPageViewController alloc] initWithThread:self.thread mediaMessage:mediaMessage]; - [vc presentFromViewController:self replacingView:imageView]; + MediaGalleryViewController *vc = + [[MediaGalleryViewController alloc] initWithThread:self.thread mediaMessage:mediaMessage]; + + [vc presentDetailViewFromViewController:self replacingView:imageView]; } - (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream diff --git a/Signal/src/ViewControllers/MediaGalleryViewController.swift b/Signal/src/ViewControllers/MediaGalleryViewController.swift new file mode 100644 index 000000000..b42dd2a24 --- /dev/null +++ b/Signal/src/ViewControllers/MediaGalleryViewController.swift @@ -0,0 +1,512 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +public struct MediaGalleryItem: Equatable { + let message: TSMessage + let attachmentStream: TSAttachmentStream + let logTag = "[MediaGalleryItem]" + + var isVideo: Bool { + return attachmentStream.isVideo() + } + + var image: UIImage { + guard let image = attachmentStream.image() else { + owsFail("\(logTag) in \(#function) unexpectedly unable to build attachment image") + return UIImage() + } + + return image + } + + // MARK: Equatable + + public static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool { + return lhs.message.uniqueId == rhs.message.uniqueId + } +} + +public struct GalleryDate: Hashable { + let year: Int + let month: Int + + init(message: TSMessage) { + let date = message.dateForSorting() + + self.year = Calendar.current.component(.year, from: date) + self.month = Calendar.current.component(.month, from: date) + } + + init(year: Int, month: Int) { + self.year = year + self.month = month + } + + private var isThisMonth: Bool { + let now = Date() + let year = Calendar.current.component(.year, from: now) + let month = Calendar.current.component(.month, from: now) + let thisMonth = GalleryDate(year: year, month: month) + + return self == thisMonth + } + + public var date: Date { + var components = DateComponents() + components.month = self.month + components.year = self.year + + return Calendar.current.date(from: components)! + } + + private var isThisYear: Bool { + let now = Date() + let thisYear = Calendar.current.component(.year, from: now) + + return self.year == thisYear + } + + static let thisYearFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM" + + return formatter + }() + + static let olderFormatter: DateFormatter = { + let formatter = DateFormatter() + + // FIXME localize for RTL, or is there a built in way to do this? + formatter.dateFormat = "MMMM yyyy" + + return formatter + }() + + var localizedString: String { + if isThisMonth { + return NSLocalizedString("MEDIA_GALLERY_THIS_MONTH_HEADER", comment: "Section header in media gallery collection view") + } else if isThisYear { + return type(of: self).thisYearFormatter.string(from: self.date) + } else { + return type(of: self).olderFormatter.string(from: self.date) + } + } + + // MARK: Hashable + + public var hashValue: Int { + return month.hashValue ^ year.hashValue + } + + // MARK: Equatable + + public static func == (lhs: GalleryDate, rhs: GalleryDate) -> Bool { + return lhs.month == rhs.month && lhs.year == rhs.year + } +} + +protocol MediaGalleryDataSource: class { + var galleryItems: [MediaGalleryItem] { get } + var galleryItemCount: Int { get } + + var sections: [GalleryDate: [MediaGalleryItem]] { get } + var sectionDates: [GalleryDate] { get } + + func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? + func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? + + func showAllMedia() + + // TODO this doesn't seem very "data-source" + func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)?) +} + +class MediaGalleryViewController: UINavigationController, MediaGalleryDataSource, MediaTileViewControllerDelegate { + + private var pageViewController: MediaPageViewController? + // private let tileViewController: MediaTileViewController + // + private let uiDatabaseConnection: YapDatabaseConnection + private let mediaGalleryFinder: OWSMediaGalleryFinder + + // FIXME get rid of `!` + private var initialGalleryItem: MediaGalleryItem! + private let thread: TSThread + private let includeGallery: Bool + + convenience init(thread: TSThread, mediaMessage: TSMessage) { + self.init(thread: thread, mediaMessage: mediaMessage, includeGallery: true) + } + + init(thread: TSThread, mediaMessage: TSMessage, includeGallery: Bool) { + self.thread = thread + self.includeGallery = includeGallery + + self.uiDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() + self.mediaGalleryFinder = OWSMediaGalleryFinder() + + super.init(nibName: nil, bundle: nil) + + uiDatabaseConnection.beginLongLivedReadTransaction() + + uiDatabaseConnection.read { transaction in + self.initialGalleryItem = self.buildGalleryItem(message: mediaMessage, transaction: transaction)! + } + + updateGalleryItems(thread: thread) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: View Lifecyle + + override func viewDidLoad() { + super.viewDidLoad() + + // UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually + // animate in our view, over the existing context, similar to a cross disolve, but allowing us to have + // more fine grained control + self.modalPresentationStyle = .custom + self.navigationBar.barTintColor = UIColor.ows_materialBlue + self.navigationBar.isTranslucent = false + self.navigationBar.isOpaque = true + + // The presentationView is only used during present/dismiss animations. + // It's a static image of the media content. + let presentationView = UIImageView() + self.presentationView = presentationView + self.view.addSubview(presentationView) + presentationView.isHidden = true + presentationView.clipsToBounds = true + presentationView.layer.allowsEdgeAntialiasing = true + presentationView.layer.minificationFilter = kCAFilterTrilinear + presentationView.layer.magnificationFilter = kCAFilterTrilinear + presentationView.contentMode = .scaleAspectFit + } + + // MARK: Present/Dismiss + + private var replacingView: UIView? + private var presentationView: UIImageView! + private var presentationViewConstraints: [NSLayoutConstraint] = [] + + // TODO rename to replacingOriginRect + private var originRect: CGRect? + + public func presentDetailView(fromViewController: UIViewController, replacingView: UIView) { + + let pageViewController = MediaPageViewController(initialItem: self.initialGalleryItem, mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection, includeGallery: self.includeGallery) + + self.pageViewController = pageViewController + self.setViewControllers([pageViewController], animated: false) + + self.replacingView = replacingView + + let convertedRect: CGRect = replacingView.convert(replacingView.bounds, to: UIApplication.shared.keyWindow) + self.originRect = convertedRect + + // loadView hasn't necessarily been called yet. + self.loadViewIfNeeded() + self.presentationView.image = self.initialGalleryItem.image + self.applyInitialMediaViewConstraints() + + // We want to animate the tapped media from it's position in the previous VC + // to it's resting place in the center of this view controller. + // + // Rather than animating the actual media view in place, we animate the presentationView, which is a static + // image of the media content. Animating the actual media view is problematic for a couple reasons: + // 1. The media view ultimately lives in a zoomable scrollView. Getting both original positioning and the final positioning + // correct, involves manipulating the zoomScale and position simultaneously, which results in non-linear movement, + // especially noticeable on high resolution images. + // 2. For Video views, the AVPlayerLayer content does not scale with the presentation animation. So you instead get a full scale + // video, wherein only the cropping is animated. + // Using a simple image view allows us to address both these problems relatively easily. + self.view.alpha = 0.0 + + guard let detailView = pageViewController.view else { + owsFail("\(logTag) in \(#function) detailView was unexpectedly nil") + return + } + + detailView.isHidden = true + + self.presentationView.isHidden = false + self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius + + fromViewController.present(self, animated: false) { + + // 1. Fade in the entire view. + UIView.animate(withDuration: 0.1) { + self.replacingView?.alpha = 0.0 + self.view.alpha = 1.0 + } + + self.presentationView.superview?.layoutIfNeeded() + self.applyFinalMediaViewConstraints() + + // 2. Animate imageView from it's initial position, which should match where it was + // in the presenting view to it's final position, front and center in this view. This + // animation duration intentionally overlaps the previous + UIView.animate(withDuration: 0.2, + delay: 0.08, + options: .curveEaseOut, + animations: { + + self.presentationView.layer.cornerRadius = 0 + self.presentationView.superview?.layoutIfNeeded() + + self.view.backgroundColor = UIColor.white + }, + completion: { (_: Bool) in + // At this point our presentation view should be overlayed perfectly + // with our media view. Swapping them out should be imperceptible. + detailView.isHidden = false + self.presentationView.isHidden = true + + self.view.isUserInteractionEnabled = true + + guard let currentPage = self.currentPage else { + owsFail("\(self.logTag) in \(#function) currentPage was unexpectedly nil") + self.dismissSelf(animated: false, completion: nil) + return + } + + if currentPage.isVideo { + currentPage.viewController.playVideo() + } + }) + } + } + + private var currentPage: MediaGalleryPage? { + return self.pageViewController!.currentPage + } + + public func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) { + self.view.isUserInteractionEnabled = false + UIApplication.shared.isStatusBarHidden = false + + guard let currentPage = self.currentPage else { + owsFail("\(logTag) in \(#function) currentItem was unexpectedly nil") + self.presentingViewController?.dismiss(animated: false, completion: completion) + return + } + + guard let detailView = pageViewController?.view else { + owsFail("\(logTag) in \(#function) detailView was unexpectedly nil") + self.presentingViewController?.dismiss(animated: false, completion: completion) + return + } + detailView.isHidden = true + self.presentationView.isHidden = false + + // Move the presentationView back to it's initial position, i.e. where + // it sits on the screen in the conversation view. + let changedItems = currentPage.galleryItem != initialGalleryItem + if changedItems { + self.presentationView.image = currentPage.image + self.applyOffscreenMediaViewConstraints() + } else { + self.applyInitialMediaViewConstraints() + } + + if isAnimated { + UIView.animate(withDuration: changedItems ? 0.25 : 0.18, + delay: 0.0, + options:.curveEaseOut, + animations: { + self.presentationView.superview?.layoutIfNeeded() + + // In case user has hidden bars, which changes background to black. + self.view.backgroundColor = UIColor.white + + if changedItems { + self.presentationView.alpha = 0 + } else { + self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius + } + }, + completion:nil) + + // This intentionally overlaps the previous animation a bit + UIView.animate(withDuration: 0.1, + delay: 0.15, + options: .curveEaseInOut, + animations: { + guard let replacingView = self.replacingView else { + owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") + self.presentingViewController?.dismiss(animated: false, completion: completion) + return + } + replacingView.alpha = 1.0 + + // fade out content and toolbars + self.navigationController?.view.alpha = 0.0 + }, + completion: { (_: Bool) in + self.presentingViewController?.dismiss(animated: false, completion: completion) + }) + } else { + guard let replacingView = self.replacingView else { + owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") + self.presentingViewController?.dismiss(animated: false, completion: completion) + return + } + replacingView.alpha = 1.0 + self.presentingViewController?.dismiss(animated: false, completion: completion) + } + } + + private func applyInitialMediaViewConstraints() { + if (self.presentationViewConstraints.count > 0) { + NSLayoutConstraint.deactivate(self.presentationViewConstraints) + self.presentationViewConstraints = [] + } + + guard let originRect = self.originRect else { + owsFail("\(logTag) in \(#function) originRect was unexpectedly nil") + return + } + + guard let presentationSuperview = self.presentationView.superview else { + owsFail("\(logTag) in \(#function) presentationView.superview was unexpectedly nil") + return + } + + let convertedRect: CGRect = presentationSuperview.convert(originRect, from: UIApplication.shared.keyWindow) + + self.presentationViewConstraints += self.presentationView.autoSetDimensions(to: convertedRect.size) + self.presentationViewConstraints += [ + self.presentationView.autoPinEdge(toSuperviewEdge: .top, withInset:convertedRect.origin.y), + self.presentationView.autoPinEdge(toSuperviewEdge: .left, withInset:convertedRect.origin.x) + ] + } + + private func applyFinalMediaViewConstraints() { + if (self.presentationViewConstraints.count > 0) { + NSLayoutConstraint.deactivate(self.presentationViewConstraints) + self.presentationViewConstraints = [] + } + + self.presentationViewConstraints = [ + self.presentationView.autoPinEdge(toSuperviewEdge: .leading), + self.presentationView.autoPinEdge(toSuperviewEdge: .top), + self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), + self.presentationView.autoPinEdge(toSuperviewEdge: .bottom) + ] + } + + private func applyOffscreenMediaViewConstraints() { + if (self.presentationViewConstraints.count > 0) { + NSLayoutConstraint.deactivate(self.presentationViewConstraints) + self.presentationViewConstraints = [] + } + + self.presentationViewConstraints += [ + self.presentationView.autoPinEdge(toSuperviewEdge: .leading), + self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), + self.presentationView.autoPinEdge(.top, to: .bottom, of: self.view) + ] + } + + // MARK: MediaGalleryDataSource + + func showAllMedia() { + // TODO fancy animation - zoom media item into it's tile in the all media grid + let allMediaController = MediaTileViewController(mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection) + allMediaController.delegate = self + + self.pushViewController(allMediaController, animated: true) + } + + var galleryItems: [MediaGalleryItem] = [] + var sections: [GalleryDate: [MediaGalleryItem]] = [:] + var sectionDates: [GalleryDate] = [] + + func buildGalleryItem(message: TSMessage, transaction: YapDatabaseReadTransaction) -> MediaGalleryItem? { + guard let attachmentStream = message.attachment(with: transaction) as? TSAttachmentStream else { + owsFail("\(self.logTag) in \(#function) attachment was unexpectedly empty") + return nil + } + + return MediaGalleryItem(message: message, attachmentStream: attachmentStream) + } + + func updateGalleryItems(thread: TSThread) { + var galleryItems: [MediaGalleryItem] = [] + var sections: [GalleryDate: [MediaGalleryItem]] = [:] + var sectionDates: [GalleryDate] = [] + + self.uiDatabaseConnection.read { transaction in + self.mediaGalleryFinder.enumerateMediaMessages(with: thread, transaction: transaction) { (message: TSMessage) in + + guard let item: MediaGalleryItem = self.buildGalleryItem(message: message, transaction: transaction) else { + owsFail("\(self.logTag) in \(#function) unexpectedly failed to buildGalleryItem") + return + } + + let date = GalleryDate(message: message) + + // TODO do we need to box this for reasonable perf? + galleryItems.append(item) + if sections[date] != nil { + // TODO do we need to box this for reasonable perf? + sections[date]!.append(item) + } else { + sectionDates.append(date) + sections[date] = [item] + } + } + } + + self.galleryItems = galleryItems + self.sections = sections + self.sectionDates = sectionDates + } + + // TODO extract to public extension? + internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? { + Logger.debug("\(logTag) in \(#function)") + + guard let currentIndex = galleryItems.index(of: currentItem) else { + owsFail("currentIndex was unexpectedly nil in \(#function)") + return nil + } + + let index: Int = galleryItems.index(after: currentIndex) + return galleryItems[safe: index] + } + + internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? { + Logger.debug("\(logTag) in \(#function)") + + guard let currentIndex = galleryItems.index(of: currentItem) else { + owsFail("currentIndex was unexpectedly nil in \(#function)") + return nil + } + + let index: Int = galleryItems.index(before: currentIndex) + return galleryItems[safe: index] + } + + var galleryItemCount: Int { + var count: UInt = 0 + self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in + count = self.mediaGalleryFinder.mediaCount(thread: self.thread, transaction: transaction) + } + return Int(count) + } + + // MARK: MediaTileViewControllerDelegate + + func mediaTileViewController(_ viewController: MediaTileViewController, didTapMediaGalleryItem mediaGalleryItem: MediaGalleryItem) { + self.pageViewController!.currentItem = mediaGalleryItem + self.popViewController(animated: true) + } + +} diff --git a/Signal/src/ViewControllers/MediaPageViewController.swift b/Signal/src/ViewControllers/MediaPageViewController.swift index 0a3287dc3..71ccdc84f 100644 --- a/Signal/src/ViewControllers/MediaPageViewController.swift +++ b/Signal/src/ViewControllers/MediaPageViewController.swift @@ -4,56 +4,78 @@ import UIKit +// TODO Can we make this private to MediaPageViewController? +public struct MediaGalleryPage: Equatable { + + public let viewController: MediaDetailViewController + public let galleryItem: MediaGalleryItem + + public var message: TSMessage { + return galleryItem.message + } + + public var attachmentStream: TSAttachmentStream { + return galleryItem.attachmentStream + } + + public var isVideo: Bool { + return galleryItem.isVideo + } + + public var image: UIImage { + return galleryItem.image + } + + // MARK: Equatable + + public static func == (lhs: MediaGalleryPage, rhs: MediaGalleryPage) -> Bool { + return lhs.galleryItem == rhs.galleryItem + } +} + class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate { - private struct MediaGalleryItem: Equatable { - let message: TSMessage - let attachmentStream: TSAttachmentStream - let viewController: MediaDetailViewController + let mediaGalleryDataSource: MediaGalleryDataSource - var isVideo: Bool { - return attachmentStream.isVideo() + private var cachedPages: [MediaGalleryPage] = [] + private var initialPage: MediaGalleryPage! + + // FIXME can this be private? + public var currentPage: MediaGalleryPage! { + return cachedPages.first { $0.viewController == viewControllers?.first } + } + + // FIXME can this be private? + public var currentItem: MediaGalleryItem! { + get { + return currentPage.galleryItem } - - var image: UIImage { - guard let image = attachmentStream.image() else { - owsFail("\(logTag) in \(#function) unexpectedly unable to build attachment image") - return UIImage() + set { + // FIXME cache separate from ordering so we don't have to clear cache + guard let galleryPage = self.buildGalleryPage(galleryItem: newValue) else { + owsFail("unexpetedly unable to build initial gallery item") + return } - return image - } - - // MARK: Equatable - - static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool { - return lhs.message.uniqueId == rhs.message.uniqueId + self.cachedPages = [galleryPage] + self.setViewControllers([galleryPage.viewController], direction: .forward, animated: false, completion: nil) } } - private var cachedItems: [MediaGalleryItem] = [] - private var initialItem: MediaGalleryItem! - private var currentItem: MediaGalleryItem! { - return cachedItems.first { $0.viewController == viewControllers?.first } - } - - private let includeGallery: Bool - private let thread: TSThread - - private let mediaGalleryFinder: OWSMediaGalleryFinder + // TODO remove? private let uiDatabaseConnection: YapDatabaseConnection - private var mediaMessages: [TSMessage] = [] + private let includeGallery: Bool - convenience init(thread: TSThread, mediaMessage: TSMessage) { - self.init(thread: thread, mediaMessage: mediaMessage, includeGallery: true) + convenience init(initialItem: MediaGalleryItem, mediaGalleryDataSource: MediaGalleryDataSource, uiDatabaseConnection: YapDatabaseConnection) { + self.init(initialItem: initialItem, mediaGalleryDataSource: mediaGalleryDataSource, uiDatabaseConnection: uiDatabaseConnection, includeGallery: true) } - init(thread: TSThread, mediaMessage: TSMessage, includeGallery: Bool) { - self.thread = thread - self.uiDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection() - self.mediaGalleryFinder = OWSMediaGalleryFinder() + init(initialItem: MediaGalleryItem, mediaGalleryDataSource: MediaGalleryDataSource, uiDatabaseConnection: YapDatabaseConnection, includeGallery: Bool) { + // TODO move responsibility to MediaGalleryVC? + self.uiDatabaseConnection = uiDatabaseConnection self.includeGallery = includeGallery + self.mediaGalleryDataSource = mediaGalleryDataSource let kSpacingBetweenItems: CGFloat = 20 @@ -64,27 +86,13 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.dataSource = self self.delegate = self - uiDatabaseConnection.beginLongLivedReadTransaction() - - if includeGallery { - uiDatabaseConnection.read { transaction in - // TODO don't read all media messages in at once. Use Mapping? - self.mediaGalleryFinder.enumerateMediaMessages(with: thread, transaction: transaction) { message in - self.mediaMessages.append(message) - } - } - } else { - self.mediaMessages = [mediaMessage] - } - - guard let initialItem = self.buildGalleryItem(mediaMessage: mediaMessage, thread: thread) else { + guard let initialPage = self.buildGalleryPage(galleryItem: initialItem) else { owsFail("unexpetedly unable to build initial gallery item") return } - self.initialItem = initialItem - cachedItems.insert(initialItem, at: 0) - - self.setViewControllers([initialItem.viewController], direction: .forward, animated: false, completion: nil) + self.initialPage = initialPage + cachedPages = [initialPage] + self.setViewControllers([initialPage.viewController], direction: .forward, animated: false, completion: nil) } @available(*, unavailable, message: "Unimplemented") @@ -96,7 +104,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou Logger.debug("\(logTag) deinit") } - var presentationView: UIImageView! var footerBar: UIToolbar! var videoPlayBarButton: UIBarButtonItem! var videoPauseBarButton: UIBarButtonItem! @@ -109,6 +116,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(didPressDismissButton)) + if includeGallery { + self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: MediaStrings.allMedia, style: .plain, target: self, action: #selector(didPressAllMediaButton)) + } + // Even though bars are opaque, we want content to be layed out behind them. // The bars might obscure part of the content, but they can easily be hidden by tapping // The alternative would be that content would shift when the navbars hide. @@ -152,18 +163,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou footerBar.autoPin(toBottomLayoutGuideOf: self, withInset: 0) footerBar.autoSetDimension(.height, toSize:kFooterHeight) - // The presentationView is only used during present/dismiss animations. - // It's a static image of the media content. - let presentationView = UIImageView(image: currentItem.image) - self.presentationView = presentationView - self.view.addSubview(presentationView) - presentationView.isHidden = true - presentationView.clipsToBounds = true - presentationView.layer.allowsEdgeAntialiasing = true - presentationView.layer.minificationFilter = kCAFilterTrilinear - presentationView.layer.magnificationFilter = kCAFilterTrilinear - presentationView.contentMode = .scaleAspectFit - // Gestures let doubleTap = UITapGestureRecognizer(target: nil, action: nil) @@ -181,6 +180,13 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // MARK: View Helpers + @objc + public func didPressAllMediaButton(sender: Any) { + Logger.debug("\(logTag) in \(#function)") + + self.mediaGalleryDataSource.showAllMedia() + } + @objc public func didSwipeView(sender: Any) { Logger.debug("\(logTag) in \(#function)") @@ -211,7 +217,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.view.backgroundColor = shouldHideToolbars ? UIColor.black : UIColor.white UIView.animate(withDuration: 0.1) { - self.currentItem.viewController.setShouldHideToolbars(self.shouldHideToolbars) + self.currentPage.viewController.setShouldHideToolbars(self.shouldHideToolbars) self.footerBar.alpha = self.shouldHideToolbars ? 0 : 1 } } @@ -230,7 +236,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil) ] - if (self.currentItem.isVideo) { + if (self.currentPage.isVideo) { toolbarItems += [ isPlayingVideo ? self.videoPauseBarButton : self.videoPlayBarButton, UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target:nil, action:nil) @@ -244,147 +250,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.footerBar.setItems(toolbarItems, animated: false) } - var replacingView: UIView? - - // TODO Default to bottom of screen? - // TODO rename to replacingOriginRect - var originRect: CGRect? - - func present(fromViewController: UIViewController, replacingView: UIView) { - - self.replacingView = replacingView - - let convertedRect: CGRect = replacingView.convert(replacingView.bounds, to: UIApplication.shared.keyWindow) - self.originRect = convertedRect - - // loadView hasn't necessarily been called yet. - self.loadViewIfNeeded() - self.applyInitialMediaViewConstraints() - - let navController = UINavigationController(rootViewController: self) - - // UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually - // animate in our view, over the existing context, similar to a cross disolve, but allowing us to have - // more fine grained control - navController.modalPresentationStyle = .custom - navController.navigationBar.barTintColor = UIColor.ows_materialBlue - navController.navigationBar.isTranslucent = false - navController.navigationBar.isOpaque = true - - // We want to animate the tapped media from it's position in the previous VC - // to it's resting place in the center of this view controller. - // - // Rather than animating the actual media view in place, we animate the presentationView, which is a static - // image of the media content. Animating the actual media view is problematic for a couple reasons: - // 1. The media view ultimately lives in a zoomable scrollView. Getting both original positioning and the final positioning - // correct, involves manipulating the zoomScale and position simultaneously, which results in non-linear movement, - // especially noticeable on high resolution images. - // 2. For Video views, the AVPlayerLayer content does not scale with the presentation animation. So you instead get a full scale - // video, wherein only the cropping is animated. - // Using a simple image view allows us to address both these problems relatively easily. - self.view.alpha = 0.0 - - self.pagerScrollView.isHidden = true - self.presentationView.isHidden = false - self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius - - fromViewController.present(navController, animated: false) { - - // 1. Fade in the entire view. - UIView.animate(withDuration: 0.1) { - self.replacingView?.alpha = 0.0 - self.view.alpha = 1.0 - } - - self.presentationView.superview?.layoutIfNeeded() - self.applyFinalMediaViewConstraints() - - // 2. Animate imageView from it's initial position, which should match where it was - // in the presenting view to it's final position, front and center in this view. This - // animation duration intentionally overlaps the previous - UIView.animate(withDuration: 0.2, - delay: 0.08, - options: .curveEaseOut, - animations: { - - self.presentationView.layer.cornerRadius = 0 - self.presentationView.superview?.layoutIfNeeded() - - self.view.backgroundColor = UIColor.white - }, - completion: { (_: Bool) in - // At this point our presentation view should be overlayed perfectly - // with our media view. Swapping them out should be imperceptible. - self.pagerScrollView.isHidden = false - self.presentationView.isHidden = true - - self.view.isUserInteractionEnabled = true - - guard let currentItem = self.currentItem else { - owsFail("\(self.logTag) in \(#function) currentItem unexepcetdly nil") - return - } - if currentItem.isVideo { - currentItem.viewController.playVideo() - } - }) - } - } - - private var presentationViewConstraints: [NSLayoutConstraint] = [] - - private func applyInitialMediaViewConstraints() { - if (self.presentationViewConstraints.count > 0) { - NSLayoutConstraint.deactivate(self.presentationViewConstraints) - self.presentationViewConstraints = [] - } - - guard let originRect = self.originRect else { - owsFail("\(logTag) in \(#function) originRect was unexpectedly nil") - return - } - - guard let presentationSuperview = self.presentationView.superview else { - owsFail("\(logTag) in \(#function) presentationView.superview was unexpectedly nil") - return - } - - let convertedRect: CGRect = presentationSuperview.convert(originRect, from: UIApplication.shared.keyWindow) - - self.presentationViewConstraints += self.presentationView.autoSetDimensions(to: convertedRect.size) - self.presentationViewConstraints += [ - self.presentationView.autoPinEdge(toSuperviewEdge: .top, withInset:convertedRect.origin.y), - self.presentationView.autoPinEdge(toSuperviewEdge: .left, withInset:convertedRect.origin.x) - ] - } - - private func applyFinalMediaViewConstraints() { - if (self.presentationViewConstraints.count > 0) { - NSLayoutConstraint.deactivate(self.presentationViewConstraints) - self.presentationViewConstraints = [] - } - - self.presentationViewConstraints = [ - self.presentationView.autoPinEdge(toSuperviewEdge: .leading), - self.presentationView.autoPinEdge(toSuperviewEdge: .top), - self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), - self.presentationView.autoPinEdge(toSuperviewEdge: .bottom) - ] - } - - private func applyOffscreenMediaViewConstraints() { - if (self.presentationViewConstraints.count > 0) { - NSLayoutConstraint.deactivate(self.presentationViewConstraints) - self.presentationViewConstraints = [] - } - - self.presentationViewConstraints += [ - self.presentationView.autoPinEdge(toSuperviewEdge: .leading), - self.presentationView.autoPinEdge(toSuperviewEdge: .trailing), - self.presentationView.autoPinEdge(.top, to: .bottom, of: self.view) - ] - } - // MARK: Actions @objc @@ -435,13 +300,13 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou assert(pendingViewControllers.count == 1) pendingViewControllers.forEach { viewController in - guard let pendingItem = self.cachedItems.first(where: { $0.viewController == viewController}) else { + guard let pendingPage = self.cachedPages.first(where: { $0.viewController == viewController}) else { owsFail("\(logTag) in \(#function) unexpected mediaDetailViewController: \(viewController)") return } // Ensure upcoming page respects current toolbar status - pendingItem.viewController.setShouldHideToolbars(self.shouldHideToolbars) + pendingPage.viewController.setShouldHideToolbars(self.shouldHideToolbars) } } @@ -450,16 +315,16 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou assert(previousViewControllers.count == 1) previousViewControllers.forEach { viewController in - guard let previousItem = self.cachedItems.first(where: { $0.viewController == viewController}) else { + guard let previousPage = self.cachedPages.first(where: { $0.viewController == viewController}) else { owsFail("\(logTag) in \(#function) unexpected mediaDetailViewController: \(viewController)") return } // Do any cleanup for the no-longer visible view controller if transitionCompleted { - previousItem.viewController.zoomOut(animated: false) - if previousItem.isVideo { - previousItem.viewController.stopVideo() + previousPage.viewController.zoomOut(animated: false) + if previousPage.isVideo { + previousPage.viewController.stopVideo() } updateFooterBarButtonItems(isPlayingVideo: false) } @@ -470,66 +335,62 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { Logger.debug("\(logTag) in \(#function)") - guard let currentIndex = cachedItems.index(where: { $0.viewController == viewController }) else { + + guard let currentIndex = cachedPages.index(where: { $0.viewController == viewController }) else { owsFail("\(self.logTag) unknown view controller. \(viewController)") return nil } - let currentItem = cachedItems[currentIndex] + let currentPage = cachedPages[currentIndex] let newIndex = currentIndex - 1 - if let cachedItem = cachedItems[safe: newIndex] { - return cachedItem.viewController + if let cachedPage = cachedPages[safe: newIndex] { + return cachedPage.viewController } - guard let previousMediaMessage = previousMediaMessage(currentItem.message) else { + guard let previousItem: MediaGalleryItem = mediaGalleryDataSource.galleryItem(before: currentPage.galleryItem) else { return nil } - guard let previousItem = buildGalleryItem(mediaMessage: previousMediaMessage, thread: thread) else { + guard let previousPage: MediaGalleryPage = buildGalleryPage(galleryItem: previousItem) else { return nil } - cachedItems.insert(previousItem, at: currentIndex) - return previousItem.viewController + cachedPages.insert(previousPage, at: currentIndex) + return previousPage.viewController } public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { Logger.debug("\(logTag) in \(#function)") - guard let currentIndex = cachedItems.index(where: { $0.viewController == viewController }) else { + guard let currentIndex = cachedPages.index(where: { $0.viewController == viewController }) else { owsFail("\(self.logTag) unknown view controller. \(viewController)") return nil } - let currentItem = cachedItems[currentIndex] + let currentPage = cachedPages[currentIndex] let newIndex = currentIndex + 1 - if let cachedItem = cachedItems[safe: newIndex] { - return cachedItem.viewController + if let cachedPage = cachedPages[safe: newIndex] { + return cachedPage.viewController } - guard let nextMediaMessage = nextMediaMessage(currentItem.message) else { + guard let nextItem: MediaGalleryItem = mediaGalleryDataSource.galleryItem(after: currentPage.galleryItem) else { return nil } - guard let nextItem = buildGalleryItem(mediaMessage: nextMediaMessage, thread: thread) else { + guard let nextPage: MediaGalleryPage = buildGalleryPage(galleryItem: nextItem) else { return nil } - cachedItems.insert(nextItem, at: newIndex) - return nextItem.viewController + cachedPages.insert(nextPage, at: newIndex) + return nextPage.viewController } - private func buildGalleryItem(mediaMessage: TSMessage, thread: TSThread) -> MediaGalleryItem? { - var fetchedAttachment: TSAttachment? = nil + private func buildGalleryPage(galleryItem: MediaGalleryItem) -> MediaGalleryPage? { var fetchedItem: ConversationViewItem? = nil self.uiDatabaseConnection.read { transaction in - fetchedAttachment = mediaMessage.attachment(with: transaction) - fetchedItem = ConversationViewItem(interaction: mediaMessage, isGroupThread: thread.isGroupThread(), transaction: transaction) - } - - guard let attachmentStream = fetchedAttachment as? TSAttachmentStream else { - owsFail("attachment stream unexpectedly nil") - return nil + let message = galleryItem.message + let thread = message.thread(with: transaction) + fetchedItem = ConversationViewItem(interaction: message, isGroupThread: thread.isGroupThread(), transaction: transaction) } guard let viewItem = fetchedItem else { @@ -537,117 +398,22 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return nil } - let viewController = MediaDetailViewController(attachmentStream: attachmentStream, viewItem: viewItem) + let viewController = MediaDetailViewController(attachmentStream: galleryItem.attachmentStream, viewItem: viewItem) viewController.delegate = self - return MediaGalleryItem(message: mediaMessage, - attachmentStream: attachmentStream, - viewController: viewController) - } - @nonobjc - public func presentationCount(for: UIPageViewController) -> Int { - Logger.debug("\(logTag) in \(#function)") - - var count: UInt = 0 - self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in - count = self.mediaGalleryFinder.mediaCount(thread: self.thread, transaction: transaction) - } - return Int(count) - } - - @nonobjc - public func presentationIndex(for pageViewController: UIPageViewController) -> Int { - Logger.debug("\(logTag) in \(#function)") - - guard let mediaPageViewController = pageViewController as? MediaPageViewController else { - owsFail("\(self.logTag) unknown view controller. \(pageViewController)") - return 0 - } - - var index: UInt = 0 - self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in - index = self.mediaGalleryFinder.mediaIndex(message: self.currentItem.message, transaction: transaction) - } - return Int(index) + return MediaGalleryPage(viewController: viewController, galleryItem: galleryItem) } // MARK: MediaDetailViewControllerDelegate public func dismissSelf(animated isAnimated: Bool, completion: (() -> Void)? = nil) { - self.view.isUserInteractionEnabled = false - UIApplication.shared.isStatusBarHidden = false - - guard let currentItem = self.currentItem else { - owsFail("\(logTag) in \(#function) currentItem was unexpectedly nil") - self.presentingViewController?.dismiss(animated: false, completion: completion) - return - } - // Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way. - currentItem.viewController.zoomOut(animated: true) - - self.pagerScrollView.isHidden = true - self.presentationView.isHidden = false - - // Move the presentationView back to it's initial position, i.e. where - // it sits on the screen in the conversation view. - let changedItems = currentItem != initialItem - if changedItems { - self.presentationView.image = currentItem.image - self.applyOffscreenMediaViewConstraints() - } else { - self.applyInitialMediaViewConstraints() - } - - if isAnimated { - UIView.animate(withDuration: changedItems ? 0.25 : 0.18, - delay: 0.0, - options:.curveEaseOut, - animations: { - self.presentationView.superview?.layoutIfNeeded() - - // In case user has hidden bars, which changes background to black. - self.view.backgroundColor = UIColor.white - - if changedItems { - self.presentationView.alpha = 0 - } else { - self.presentationView.layer.cornerRadius = OWSMessageCellCornerRadius - } - }, - completion:nil) - - // This intentionally overlaps the previous animation a bit - UIView.animate(withDuration: 0.1, - delay: 0.15, - options: .curveEaseInOut, - animations: { - guard let replacingView = self.replacingView else { - owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") - self.presentingViewController?.dismiss(animated: false, completion: completion) - return - } - replacingView.alpha = 1.0 - - // fade out content and toolbars - self.navigationController?.view.alpha = 0.0 - }, - completion: { (_: Bool) in - self.presentingViewController?.dismiss(animated: false, completion: completion) - }) - } else { - guard let replacingView = self.replacingView else { - owsFail("\(self.logTag) in \(#function) replacingView was unexpectedly nil") - self.presentingViewController?.dismiss(animated: false, completion: completion) - return - } - replacingView.alpha = 1.0 - self.presentingViewController?.dismiss(animated: false, completion: completion) - } + currentPage.viewController.zoomOut(animated: true) + self.mediaGalleryDataSource.dismissSelf(animated: isAnimated, completion: completion) } public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) { - guard mediaDetailViewController == currentItem.viewController else { + guard mediaDetailViewController == currentPage.viewController else { Logger.verbose("\(logTag) in \(#function) ignoring stale delegate.") return } @@ -655,40 +421,4 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.shouldHideToolbars = isPlayingVideo self.updateFooterBarButtonItems(isPlayingVideo: isPlayingVideo) } - - // MARK: Helpers - - private var threadId: String { - guard let unqiueThreadId = self.thread.uniqueId else { - owsFail("thread missing id in \(#function)") - return "" - } - - return unqiueThreadId - } - - private func nextMediaMessage(_ message: TSMessage) -> TSMessage? { - Logger.debug("\(logTag) in \(#function)") - - guard let currentIndex = mediaMessages.index(of: message) else { - owsFail("currentIndex was unexpectedly nil in \(#function)") - return nil - } - - let index: Int = mediaMessages.index(after: currentIndex) - return mediaMessages[safe: index] - } - - private func previousMediaMessage(_ message: TSMessage) -> TSMessage? { - Logger.debug("\(logTag) in \(#function)") - - guard let currentIndex = mediaMessages.index(of: message) else { - owsFail("currentIndex was unexpectedly nil in \(#function)") - return nil - } - - let index: Int = mediaMessages.index(before: currentIndex) - return mediaMessages[safe: index] - } } - diff --git a/Signal/src/ViewControllers/MediaTileViewController.swift b/Signal/src/ViewControllers/MediaTileViewController.swift new file mode 100644 index 000000000..289cb21d2 --- /dev/null +++ b/Signal/src/ViewControllers/MediaTileViewController.swift @@ -0,0 +1,303 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +public protocol MediaTileViewControllerDelegate: class { + func mediaTileViewController(_ viewController: MediaTileViewController, didTapMediaGalleryItem mediaGalleryItem: MediaGalleryItem) +} + +public class MediaTileViewController: UICollectionViewController, MediaGalleryCellDelegate { + + // TODO weak? + private var mediaGalleryDataSource: MediaGalleryDataSource + + private var sections: [GalleryDate: [MediaGalleryItem]] { + return mediaGalleryDataSource.sections + } + private var sectionDates: [GalleryDate] { + return mediaGalleryDataSource.sectionDates + } + + private let uiDatabaseConnection: YapDatabaseConnection + + public weak var delegate: MediaTileViewControllerDelegate? + + let kSectionHeaderReuseIdentifier = "kSectionHeaderReuseIdentifier" + let kCellReuseIdentifier = "kCellReuseIdentifier" + + init(mediaGalleryDataSource: MediaGalleryDataSource, uiDatabaseConnection: YapDatabaseConnection) { + + self.mediaGalleryDataSource = mediaGalleryDataSource + self.uiDatabaseConnection = uiDatabaseConnection + + // Layout Setup + + let screenWidth = UIScreen.main.bounds.size.width + let kItemsPerRow = 4 + let kInterItemSpacing: CGFloat = 2 + + let availableWidth = screenWidth - CGFloat(kItemsPerRow + 1) * kInterItemSpacing + let kItemWidth = floor(availableWidth / CGFloat(kItemsPerRow)) + + let layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout() + layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + layout.itemSize = CGSize(width: kItemWidth, height: kItemWidth) + layout.minimumInteritemSpacing = kInterItemSpacing + layout.minimumLineSpacing = kInterItemSpacing + layout.sectionHeadersPinToVisibleBounds = true + + let kHeaderHeight: CGFloat = 50 + layout.headerReferenceSize = CGSize(width: 0, height: kHeaderHeight) + + super.init(collectionViewLayout: layout) + + updateSections() + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: View Lifecycle Overrides + + override public func viewDidLoad() { + super.viewDidLoad() + + self.title = MediaStrings.allMedia + + guard let collectionView = self.collectionView else { + owsFail("\(logTag) in \(#function) collectionView was unexpectedly nil") + return + } + collectionView.backgroundColor = UIColor.white + collectionView.register(MediaGalleryCell.self, forCellWithReuseIdentifier: kCellReuseIdentifier) + collectionView.register(MediaGallerySectionHeader.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: kSectionHeaderReuseIdentifier) + + // feels a bit weird to have content smashed all the way to the bottom edge. + collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) + + // FIXME: For some reason this is scrolling not *quite* to the bottom in viewDidLoad. + // It does work in viewDidAppear. What changes? + self.view.layoutIfNeeded() + scrollToBottom(animated: false) + } + + // MARK: UIColletionViewDataSource + + override public func numberOfSections(in collectionView: UICollectionView) -> Int { + return sections.keys.count + } + + override public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int { + guard let sectionDate = self.sectionDates[safe: sectionIdx] else { + owsFail("\(logTag) in \(#function) unknown section: \(sectionIdx)") + return 0 + } + + guard let section = self.sections[sectionDate] else { + owsFail("\(logTag) in \(#function) no section for date: \(sectionDate)") + return 0 + } + + // We shouldn't show empty sections + assert(section.count > 0) + + return section.count + } + + override public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + + let defaultView = UICollectionReusableView() + if (kind == UICollectionElementKindSectionHeader) { + guard let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kSectionHeaderReuseIdentifier, for: indexPath) as? MediaGallerySectionHeader else { + owsFail("\(logTag) in \(#function) unable to build section header for indexPath: \(indexPath)") + return defaultView + } + guard let date = self.sectionDates[safe: indexPath.section] else { + owsFail("\(logTag) in \(#function) unknown section for indexPath: \(indexPath)") + return defaultView + } + + sectionHeader.configure(title: date.localizedString) + return sectionHeader + } + + return defaultView + } + + override public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + let defaultCell = UICollectionViewCell() + + guard let sectionDate = self.sectionDates[safe: indexPath.section] else { + owsFail("\(logTag) in \(#function) unknown section: \(indexPath.section)") + return defaultCell + } + + guard let section = self.sections[sectionDate] else { + owsFail("\(logTag) in \(#function) no section for date: \(sectionDate)") + return defaultCell + } + + guard let galleryItem = section[safe: indexPath.row] else { + owsFail("\(logTag) in \(#function) no message for row: \(indexPath.row)") + return defaultCell + } + + guard let cell = self.collectionView?.dequeueReusableCell(withReuseIdentifier: kCellReuseIdentifier, for: indexPath) as? MediaGalleryCell else { + owsFail("\(logTag) in \(#function) unexptected cell for indexPath: \(indexPath)") + return defaultCell + } + + cell.configure(item: galleryItem, delegate: self) + + return cell + } + + // MARK: MediaGalleryDelegate + + public func didTapCell(_ cell: MediaGalleryCell, item: MediaGalleryItem) { + Logger.debug("\(logTag) in \(#function)") + self.delegate?.mediaTileViewController(self, didTapMediaGalleryItem: item) + } + + // MARK: Util + + private func scrollToBottom(animated isAnimated: Bool) { + guard let collectionView = self.collectionView else { + owsFail("\(self.logTag) in \(#function) collectionView was unexpectedly nil") + return + } + + let yOffset: CGFloat = collectionView.contentSize.height - collectionView.bounds.size.height + collectionView.contentInset.bottom + let offset: CGPoint = CGPoint(x: 0, y: yOffset) + + collectionView.setContentOffset(offset, animated: isAnimated) + } + + // TODO? dbModified? Is this even necessary? + private func updateSections() { + self.collectionView?.reloadData() + } + +} + +class MediaGallerySectionHeader: UICollectionReusableView { + + // HACK: scrollbar incorrectly appears *behind* section headers + // in collection view on iOS11 =( + private class AlwaysOnTopLayer: CALayer { + override var zPosition: CGFloat { + get { return 0 } + set {} + } + } + + let label: UILabel + + override class var layerClass: AnyClass { + get { + // HACK: scrollbar incorrectly appears *behind* section headers + // in collection view on iOS11 =( + if #available(iOS 11, *) { + return AlwaysOnTopLayer.self + } else { + return super.layerClass + } + } + } + + override init(frame: CGRect) { + label = UILabel() + + let blurEffect = UIBlurEffect(style: .light) + let blurEffectView = UIVisualEffectView(effect: blurEffect) + + blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + super.init(frame: frame) + + self.addSubview(blurEffectView) + self.addSubview(label) + + blurEffectView.autoPinEdgesToSuperviewEdges() + label.autoPinEdge(toSuperviewEdge: .trailing) + label.autoPinEdge(toSuperviewEdge: .leading, withInset: 10) + label.autoVCenterInSuperview() + } + + @available(*, unavailable, message: "Unimplemented") + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func configure(title: String) { + self.label.text = title + } + + override public func prepareForReuse() { + super.prepareForReuse() + + self.label.text = nil + } +} + +public protocol MediaGalleryCellDelegate: class { + func didTapCell(_ cell: MediaGalleryCell, item: MediaGalleryItem) +} + +public class MediaGalleryCell: UICollectionViewCell { + + private let imageView: UIImageView + private var tapGesture: UITapGestureRecognizer! + + private var item: MediaGalleryItem? + public weak var delegate: MediaGalleryCellDelegate? + + override init(frame: CGRect) { + self.imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + + super.init(frame: frame) + + self.tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + self.addGestureRecognizer(tapGesture) + + self.clipsToBounds = true + self.addSubview(imageView) + + imageView.autoPinEdgesToSuperviewEdges() + } + + @available(*, unavailable, message: "Unimplemented") + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func configure(item: MediaGalleryItem, delegate: MediaGalleryCellDelegate) { + self.item = item + self.imageView.image = item.image + self.delegate = delegate + } + + override public func prepareForReuse() { + super.prepareForReuse() + + self.item = nil + self.imageView.image = nil + self.delegate = nil + } + + // MARK: Events + + func didTap(gestureRecognizer: UITapGestureRecognizer) { + guard let item = self.item else { + owsFail("\(logTag) item was unexpectedly nil") + return + } + + self.delegate?.didTapCell(self, item: item) + } +} diff --git a/Signal/src/ViewControllers/MessageDetailViewController.swift b/Signal/src/ViewControllers/MessageDetailViewController.swift index 72bd0cd83..4bc62a6ef 100644 --- a/Signal/src/ViewControllers/MessageDetailViewController.swift +++ b/Signal/src/ViewControllers/MessageDetailViewController.swift @@ -757,12 +757,12 @@ class MessageDetailViewController: OWSViewController, UIScrollViewDelegate, Medi // MARK: MediaDetailPresenter public func presentDetails(mediaMessageView: MediaMessageView, fromView: UIView) { - guard let attachmentStream = self.attachmentStream else { + guard self.attachmentStream != nil else { owsFail("attachment stream unexpectedly nil") return } - let mediaPageViewController = MediaPageViewController(thread: self.thread, mediaMessage: self.message, includeGallery: false) - mediaPageViewController.present(fromViewController: self, replacingView: fromView) + let mediaGalleryViewController = MediaGalleryViewController(thread: self.thread, mediaMessage: self.message, includeGallery: false) + mediaGalleryViewController.presentDetailView(fromViewController: self, replacingView: fromView) } } diff --git a/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m b/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m index 0156192dd..a2a7253ef 100644 --- a/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m +++ b/Signal/src/ViewControllers/ThreadSettings/OWSConversationSettingsViewController.m @@ -330,6 +330,14 @@ NS_ASSUME_NONNULL_BEGIN }]]; } + [mainSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ + // FIXME proper icon + return [weakSelf disclosureCellWithName:MediaStrings.allMedia iconName:@"actionsheet_camera_roll_black"]; + } + actionBlock:^{ + [weakSelf showMediaGallery]; + }]]; + [mainSection addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = [UITableViewCell new]; @@ -1152,6 +1160,12 @@ NS_ASSUME_NONNULL_BEGIN [self updateTableContents]; } +- (void)showMediaGallery +{ + DDLogDebug(@"%@ in showMediaGallery", self.logTag); + + // [[AllMediaViewController alloc] initWithThread:self.thread]; +} #pragma mark - Notifications - (void)identityStateDidChange:(NSNotification *)notification diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 0278713a1..64aea7e2c 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -956,6 +956,9 @@ /* No comment provided by engineer. */ "LOGGING_SECTION" = "Logging"; +/* nav bar button item */ +"MEDIA_DETAIL_VIEW_ALL_MEDIA_BUTTON" = "All Media"; + /* media picker option to take photo or video */ "MEDIA_FROM_CAMERA_BUTTON" = "Camera"; @@ -965,6 +968,9 @@ /* media picker option to choose from library */ "MEDIA_FROM_LIBRARY_BUTTON" = "Photo Library"; +/* Section header in media gallery collection view */ +"MEDIA_GALLERY_THIS_MONTH_HEADER" = "This Month"; + /* Title for the 'message approval' dialog. */ "MESSAGE_APPROVAL_DIALOG_TITLE" = "Message"; diff --git a/SignalMessaging/categories/Sequence+OWS.swift b/SignalMessaging/categories/Sequence+OWS.swift new file mode 100644 index 000000000..566c3967b --- /dev/null +++ b/SignalMessaging/categories/Sequence+OWS.swift @@ -0,0 +1,29 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +fileprivate class Box { + var value: A + init(_ val: A) { + self.value = val + } +} + +public extension Sequence { + // `group` function lifted from https://stackoverflow.com/a/31220067 + public func group(by key: (Iterator.Element) -> U) -> [U:[Iterator.Element]] { + var categories: [U: Box<[Iterator.Element]>] = [:] + for element in self { + let key = key(element) + // We use a Box type to avoid copying the entire array every time we mutate it. + if case nil = categories[key]?.value.append(element) { + categories[key] = Box([element]) + } + } + var result: [U: [Iterator.Element]] = Dictionary(minimumCapacity: categories.count) + for (key, val) in categories { + result[key] = val.value + } + return result + } +} diff --git a/SignalMessaging/views/CommonStrings.swift b/SignalMessaging/views/CommonStrings.swift index 8ee0a1783..98434b398 100644 --- a/SignalMessaging/views/CommonStrings.swift +++ b/SignalMessaging/views/CommonStrings.swift @@ -59,6 +59,10 @@ import Foundation static public let missedCallWithIdentityChangeNotificationBodyWithCallerName = NSLocalizedString("MISSED_CALL_WITH_CHANGED_IDENTITY_BODY_WITH_CALLER_NAME", comment: "notification title. Embeds {{caller's name or phone number}}") } +@objc public class MediaStrings: NSObject { + static public let allMedia = NSLocalizedString("MEDIA_DETAIL_VIEW_ALL_MEDIA_BUTTON", comment: "nav bar button item") +} + @objc public class SafetyNumberStrings: NSObject { @objc static public let confirmSendButton = NSLocalizedString("SAFETY_NUMBER_CHANGED_CONFIRM_SEND_ACTION", diff --git a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m index 3cdad90c0..778bb3f94 100644 --- a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m +++ b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m @@ -274,7 +274,7 @@ NS_ASSUME_NONNULL_BEGIN - (nullable UIImage *)image { if ([self isVideo]) { - return [self videoThumbnail]; + return [self videoStillImage]; } else if ([self isImage] || [self isAnimated]) { NSURL *_Nullable mediaUrl = [self mediaURL]; if (!mediaUrl) { @@ -290,7 +290,7 @@ NS_ASSUME_NONNULL_BEGIN } } -- (nullable UIImage *)videoThumbnail +- (nullable UIImage *)videoStillImage { NSURL *_Nullable mediaUrl = [self mediaURL]; if (!mediaUrl) { @@ -330,7 +330,7 @@ NS_ASSUME_NONNULL_BEGIN - (CGSize)calculateImageSize { if ([self isVideo]) { - return [self videoThumbnail].size; + return [self videoStillImage].size; } else if ([self isImage] || [self isAnimated]) { NSURL *_Nullable mediaUrl = [self mediaURL]; if (!mediaUrl) {