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
This commit is contained in:
Michael Kirk 2018-03-15 13:46:29 -04:00
parent e5b1c0c9b4
commit 985af76d0b
11 changed files with 1001 additions and 390 deletions

View File

@ -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 = "<group>"; };
452D1EE71DCA90D100A57EC4 /* MesssagesBubblesSizeCalculatorTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MesssagesBubblesSizeCalculatorTest.swift; path = Models/MesssagesBubblesSizeCalculatorTest.swift; sourceTree = "<group>"; };
452EA09D1EA7ABE00078744B /* AttachmentPointerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPointerView.swift; sourceTree = "<group>"; };
452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewController.swift; sourceTree = "<group>"; };
452ECA4C1E087E7200E2F016 /* MessageFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageFetcherJob.swift; sourceTree = "<group>"; };
453034AA200289F50018945D /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
453CC0361D08E1A60040EBA3 /* sn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sn; path = translations/sn.lproj/Localizable.strings; sourceTree = "<group>"; };
45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataChannelMessage.swift; sourceTree = "<group>"; };
454A84032059C787008B8C75 /* MediaTileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTileViewController.swift; sourceTree = "<group>"; };
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 = "<group>"; };
4551DB59205C562300C8AE75 /* Collection+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+OWS.swift"; sourceTree = "<group>"; };
4551DB5D205C692A00C8AE75 /* Sequence+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sequence+OWS.swift"; sourceTree = "<group>"; };
4556FA671F54AA9500AF40DD /* DebugUIProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugUIProfile.swift; sourceTree = "<group>"; };
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 = "<group>";
@ -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 */,

View File

@ -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

View File

@ -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)
}
}

View File

@ -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]
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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";

View File

@ -0,0 +1,29 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
fileprivate class Box<A> {
var value: A
init(_ val: A) {
self.value = val
}
}
public extension Sequence {
// `group` function lifted from https://stackoverflow.com/a/31220067
public func group<U: Hashable>(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
}
}

View File

@ -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",

View File

@ -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) {