mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
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:
parent
e5b1c0c9b4
commit
985af76d0b
11 changed files with 1001 additions and 390 deletions
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
|
|
512
Signal/src/ViewControllers/MediaGalleryViewController.swift
Normal file
512
Signal/src/ViewControllers/MediaGalleryViewController.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -4,56 +4,78 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate {
|
||||
// TODO Can we make this private to MediaPageViewController?
|
||||
public struct MediaGalleryPage: Equatable {
|
||||
|
||||
private struct MediaGalleryItem: Equatable {
|
||||
let message: TSMessage
|
||||
let attachmentStream: TSAttachmentStream
|
||||
let viewController: MediaDetailViewController
|
||||
public let viewController: MediaDetailViewController
|
||||
public let galleryItem: MediaGalleryItem
|
||||
|
||||
var isVideo: Bool {
|
||||
return attachmentStream.isVideo()
|
||||
public var message: TSMessage {
|
||||
return galleryItem.message
|
||||
}
|
||||
|
||||
var image: UIImage {
|
||||
guard let image = attachmentStream.image() else {
|
||||
owsFail("\(logTag) in \(#function) unexpectedly unable to build attachment image")
|
||||
return UIImage()
|
||||
public var attachmentStream: TSAttachmentStream {
|
||||
return galleryItem.attachmentStream
|
||||
}
|
||||
|
||||
return image
|
||||
public var isVideo: Bool {
|
||||
return galleryItem.isVideo
|
||||
}
|
||||
|
||||
public var image: UIImage {
|
||||
return galleryItem.image
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool {
|
||||
return lhs.message.uniqueId == rhs.message.uniqueId
|
||||
public static func == (lhs: MediaGalleryPage, rhs: MediaGalleryPage) -> Bool {
|
||||
return lhs.galleryItem == rhs.galleryItem
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedItems: [MediaGalleryItem] = []
|
||||
private var initialItem: MediaGalleryItem!
|
||||
private var currentItem: MediaGalleryItem! {
|
||||
return cachedItems.first { $0.viewController == viewControllers?.first }
|
||||
class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate {
|
||||
|
||||
let mediaGalleryDataSource: MediaGalleryDataSource
|
||||
|
||||
private var cachedPages: [MediaGalleryPage] = []
|
||||
private var initialPage: MediaGalleryPage!
|
||||
|
||||
// FIXME can this be private?
|
||||
public var currentPage: MediaGalleryPage! {
|
||||
return cachedPages.first { $0.viewController == viewControllers?.first }
|
||||
}
|
||||
|
||||
private let includeGallery: Bool
|
||||
private let thread: TSThread
|
||||
// FIXME can this be private?
|
||||
public var currentItem: MediaGalleryItem! {
|
||||
get {
|
||||
return currentPage.galleryItem
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
private let mediaGalleryFinder: OWSMediaGalleryFinder
|
||||
self.cachedPages = [galleryPage]
|
||||
self.setViewControllers([galleryPage.viewController], direction: .forward, animated: false, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
303
Signal/src/ViewControllers/MediaTileViewController.swift
Normal file
303
Signal/src/ViewControllers/MediaTileViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
29
SignalMessaging/categories/Sequence+OWS.swift
Normal file
29
SignalMessaging/categories/Sequence+OWS.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue