diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index bf40528d2..b57b434a8 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -450,6 +450,7 @@ 4C858A52212DC5E1001B45D3 /* UIImage+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C858A51212DC5E1001B45D3 /* UIImage+OWS.swift */; }; 4C948FF72146EB4800349F0D /* BlockListCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C948FF62146EB4800349F0D /* BlockListCache.swift */; }; 4C9CA25D217E676900607C63 /* ZXingObjC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C9CA25C217E676900607C63 /* ZXingObjC.framework */; }; + 4CA46F4A219C78050038ABDE /* GalleryRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA46F49219C78050038ABDE /* GalleryRailView.swift */; }; 4CA5F793211E1F06008C2708 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA5F792211E1F06008C2708 /* Toast.swift */; }; 4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF4C0920F55BBA005DA313 /* MenuActionsViewController.swift */; }; 4CB5F26920F7D060004D1B42 /* MessageActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB5F26820F7D060004D1B42 /* MessageActions.swift */; }; @@ -1137,7 +1138,6 @@ 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; 4C1885CF218D0EA800B67051 /* ImagePickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = ""; }; 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridViewCell.swift; sourceTree = ""; }; - 4C1D233C218B96A000A0598F /* typing-animation.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; name = "typing-animation.gif"; path = "../../../../../Downloads/typing-animation.gif"; sourceTree = ""; }; 4C1D2333218B692800A0598F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = translations/ko.lproj/Localizable.strings; sourceTree = ""; }; 4C1D2334218B6A1100A0598F /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = translations/az.lproj/Localizable.strings; sourceTree = ""; }; 4C1D2335218B6A7600A0598F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = translations/el.lproj/Localizable.strings; sourceTree = ""; }; @@ -1159,6 +1159,7 @@ 4C858A51212DC5E1001B45D3 /* UIImage+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+OWS.swift"; sourceTree = ""; }; 4C948FF62146EB4800349F0D /* BlockListCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListCache.swift; sourceTree = ""; }; 4C9CA25C217E676900607C63 /* ZXingObjC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZXingObjC.framework; path = ThirdParty/Carthage/Build/iOS/ZXingObjC.framework; sourceTree = ""; }; + 4CA46F49219C78050038ABDE /* GalleryRailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRailView.swift; sourceTree = ""; }; 4CA5F792211E1F06008C2708 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 4CB5F26820F7D060004D1B42 /* MessageActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActions.swift; sourceTree = ""; }; 4CB93DC12180FF07004B9764 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitoringManager.swift; sourceTree = ""; }; @@ -2269,6 +2270,7 @@ 4CA5F792211E1F06008C2708 /* Toast.swift */, 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */, + 4CA46F49219C78050038ABDE /* GalleryRailView.swift */, ); name = Views; path = views; @@ -3448,6 +3450,7 @@ 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 45F659821E1BE77000444429 /* NonCallKitCallUIAdaptee.swift in Sources */, 45AE48511E0732D6004D96C2 /* TurnServerInfo.swift in Sources */, + 4CA46F4A219C78050038ABDE /* GalleryRailView.swift in Sources */, 34B3F8771E8DF1700035BE1A /* ContactsPicker.swift in Sources */, 45C0DC1B1E68FE9000E04C47 /* UIApplication+OWS.swift in Sources */, 45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */, diff --git a/Signal/src/ViewControllers/MediaGalleryViewController.swift b/Signal/src/ViewControllers/MediaGalleryViewController.swift index cdc2b15f5..afd4ff1bc 100644 --- a/Signal/src/ViewControllers/MediaGalleryViewController.swift +++ b/Signal/src/ViewControllers/MediaGalleryViewController.swift @@ -8,17 +8,41 @@ public enum GalleryDirection { case before, after, around } +class MediaGalleryAlbum { + private(set) var items: [MediaGalleryItem] + + init(items: [MediaGalleryItem]) { + self.items = items + } + + func add(item: MediaGalleryItem) { + guard !items.contains(item) else { + return + } + + items.append(item) + items.sort { (lhs, rhs) -> Bool in + return lhs.albumIndex < rhs.albumIndex + } + } +} + public class MediaGalleryItem: Equatable, Hashable { let message: TSMessage let attachmentStream: TSAttachmentStream let galleryDate: GalleryDate let captionForDisplay: String? + let albumIndex: Int + var album: MediaGalleryAlbum? + let orderingKey: MediaGalleryItemOrderingKey init(message: TSMessage, attachmentStream: TSAttachmentStream) { self.message = message self.attachmentStream = attachmentStream self.captionForDisplay = attachmentStream.caption?.filterForDisplay self.galleryDate = GalleryDate(message: message) + self.albumIndex = message.attachmentIds.index(of: attachmentStream.uniqueId!) + self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.timestampForSorting(), attachmentSortKey: albumIndex) } var isVideo: Bool { @@ -33,6 +57,10 @@ public class MediaGalleryItem: Equatable, Hashable { return attachmentStream.isImage } + var imageSize: CGSize { + return attachmentStream.imageSize() + } + public typealias AsyncThumbnailBlock = (UIImage) -> Void func thumbnailImage(async:@escaping AsyncThumbnailBlock) -> UIImage? { return attachmentStream.thumbnailImageSmall(success: async, failure: {}) @@ -49,6 +77,29 @@ public class MediaGalleryItem: Equatable, Hashable { public var hashValue: Int { return attachmentStream.uniqueId?.hashValue ?? attachmentStream.hashValue } + + // MARK: Sorting + + struct MediaGalleryItemOrderingKey: Comparable { + let messageSortKey: UInt64 + let attachmentSortKey: Int + + // MARK: Comparable + + static func < (lhs: MediaGalleryItem.MediaGalleryItemOrderingKey, rhs: MediaGalleryItem.MediaGalleryItemOrderingKey) -> Bool { + if lhs.messageSortKey < rhs.messageSortKey { + return true + } + + if lhs.messageSortKey == rhs.messageSortKey { + if lhs.attachmentSortKey < rhs.attachmentSortKey { + return true + } + } + + return false + } + } } public struct GalleryDate: Hashable, Comparable, Equatable { @@ -648,7 +699,27 @@ class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDel return nil } - return MediaGalleryItem(message: message, attachmentStream: attachmentStream) + let galleryItem = MediaGalleryItem(message: message, attachmentStream: attachmentStream) + galleryItem.album = getAlbum(item: galleryItem) + + return galleryItem + } + + var galleryAlbums: [String: MediaGalleryAlbum] = [:] + func getAlbum(item: MediaGalleryItem) -> MediaGalleryAlbum? { + guard let albumMessageId = item.attachmentStream.albumMessageId else { + return nil + } + + guard let existingAlbum = galleryAlbums[albumMessageId] else { + let newAlbum = MediaGalleryAlbum(items: [item]) + galleryAlbums[albumMessageId] = newAlbum + + return newAlbum + } + + existingAlbum.add(item: item) + return existingAlbum } // Range instead of indexSet since it's contiguous? @@ -760,13 +831,13 @@ class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDel Bench(title: "sorting gallery items") { galleryItems.sort { lhs, rhs -> Bool in - return lhs.message.timestampForSorting() < rhs.message.timestampForSorting() + return lhs.orderingKey < rhs.orderingKey } sectionDates.sort() for (date, galleryItems) in sections { sortedSections[date] = galleryItems.sorted { lhs, rhs -> Bool in - return lhs.message.timestampForSorting() < rhs.message.timestampForSorting() + return lhs.orderingKey < rhs.orderingKey } } } diff --git a/Signal/src/ViewControllers/MediaPageViewController.swift b/Signal/src/ViewControllers/MediaPageViewController.swift index d33c11e59..f8dbfc1df 100644 --- a/Signal/src/ViewControllers/MediaPageViewController.swift +++ b/Signal/src/ViewControllers/MediaPageViewController.swift @@ -3,6 +3,7 @@ // import UIKit +import PromiseKit // Objc wrapper for the MediaGalleryItem struct @objc @@ -53,10 +54,11 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return } - self.updateTitle(item: item) - self.updateCaption(item: item) - self.setViewControllers([galleryPage], direction: direction, animated: isAnimated) - self.updateFooterBarButtonItems(isPlayingVideo: false) + updateTitle(item: item) + updateCaption(item: item) + setViewControllers([galleryPage], direction: direction, animated: isAnimated) + updateFooterBarButtonItems(isPlayingVideo: false) + updateMediaRail() } private let uiDatabaseConnection: YapDatabaseConnection @@ -108,6 +110,26 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou var currentCaptionView: CaptionView! var pendingCaptionView: CaptionView! + // MARK: ImageRail + + var galleryRailView: GalleryRailView! + + private func makeClearToolbar() -> UIToolbar { + let toolbar = UIToolbar() + + toolbar.backgroundColor = UIColor.clear + + // Making a toolbar transparent requires setting an empty uiimage + toolbar.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: .default) + + // hide 1px top-border + toolbar.clipsToBounds = true + + return toolbar + } + + // MARK: + override func viewDidLoad() { super.viewDidLoad() @@ -120,8 +142,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.navigationItem.titleView = portraitHeaderView - self.updateTitle() - if showAllMediaButton { self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: MediaStrings.allMedia, style: .plain, target: self, action: #selector(didPressAllMediaButton)) } @@ -151,15 +171,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // Views - let kFooterHeight: CGFloat = 44 - view.backgroundColor = Theme.backgroundColor - let footerBar = UIToolbar() - self.footerBar = footerBar - let captionViewsContainer = UIView() - captionViewsContainer.setContentHuggingHigh() captionViewsContainer.setCompressionResistanceHigh() @@ -177,9 +191,24 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou pendingCaptionView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top) pendingCaptionView.autoPinEdge(toSuperviewEdge: .top, withInset: 0, relation: .greaterThanOrEqual) + let galleryRailView = GalleryRailView() + galleryRailView.delegate = self + galleryRailView.autoSetDimension(.height, toSize: 60) + self.galleryRailView = galleryRailView + + let footerBar = self.makeClearToolbar() + self.footerBar = footerBar + let bottomContainer = UIView() self.bottomContainer = bottomContainer - let bottomStack = UIStackView(arrangedSubviews: [captionViewsContainer, footerBar]) + + let toolbarStack = UIStackView(arrangedSubviews: [galleryRailView, footerBar]) + toolbarStack.axis = .vertical + let toolbarBarBlurView = UIVisualEffectView(effect: Theme.barBlurEffect) + toolbarStack.insertSubview(toolbarBarBlurView, at: 0) + toolbarBarBlurView.autoPinEdgesToSuperviewEdges() + + let bottomStack = UIStackView(arrangedSubviews: [captionViewsContainer, toolbarStack]) bottomStack.axis = .vertical bottomContainer.addSubview(bottomStack) bottomStack.autoPinEdgesToSuperviewEdges() @@ -187,12 +216,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.videoPlayBarButton = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(didPressPlayBarButton)) self.videoPauseBarButton = UIBarButtonItem(barButtonSystemItem: .pause, target: self, action: #selector(didPressPauseBarButton)) - self.updateFooterBarButtonItems(isPlayingVideo: true) self.view.addSubview(bottomContainer) bottomContainer.autoPinWidthToSuperview() bottomContainer.autoPinEdge(toSuperviewEdge: .bottom) footerBar.autoPin(toBottomLayoutGuideOf: self, withInset: 0) - footerBar.autoSetDimension(.height, toSize: kFooterHeight) + footerBar.autoSetDimension(.height, toSize: 44) + + updateTitle() + updateMediaRail() + updateFooterBarButtonItems(isPlayingVideo: true) // Gestures @@ -309,6 +341,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.footerBar.setItems(toolbarItems, animated: false) } + func updateMediaRail() { + guard let currentItem = self.currentItem else { + owsFailDebug("currentItem was unexpectedly nil") + return + } + + galleryRailView.configure(itemProvider: currentItem.album, focusedItem: currentItem) + } + // MARK: Actions @objc @@ -484,6 +525,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } updateTitle() + updateMediaRail() previousPage.zoomOut(animated: false) previousPage.stopAnyVideo() updateFooterBarButtonItems(isPlayingVideo: false) @@ -747,6 +789,19 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } } +extension MediaPageViewController: GalleryRailViewDelegate { + func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) { + guard let targetItem = imageRailItem as? MediaGalleryItem else { + owsFailDebug("unexpected imageRailItem: \(imageRailItem)") + return + } + + let direction: NavigationDirection = currentItem.albumIndex < targetItem.albumIndex ? .forward : .reverse + + self.setCurrentItem(targetItem, direction: direction, animated: true) + } +} + class CaptionView: UIView { var text: String? { diff --git a/Signal/src/views/GalleryRailView.swift b/Signal/src/views/GalleryRailView.swift new file mode 100644 index 000000000..6e7782a38 --- /dev/null +++ b/Signal/src/views/GalleryRailView.swift @@ -0,0 +1,274 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import PromiseKit + +protocol GalleryRailItemProvider: class { + var railItems: [GalleryRailItem] { get } +} + +protocol GalleryRailItem: class { + func getRailImage() -> Guarantee + var aspectRatio: CGFloat { get } +} + +extension CGSize { + var aspectRatio: CGFloat { + guard self.height > 0 else { + return 0 + } + + return self.width / self.height + } +} + +extension MediaGalleryItem: GalleryRailItem { + var aspectRatio: CGFloat { + return self.imageSize.aspectRatio + } + + func getRailImage() -> Guarantee { + let (guarantee, fulfill) = Guarantee.pending() + if let image = self.thumbnailImage(async: { fulfill($0) }) { + fulfill(image) + } + + return guarantee + } +} + +extension MediaGalleryAlbum: GalleryRailItemProvider { + var railItems: [GalleryRailItem] { + return self.items + } +} + +protocol GalleryRailCellViewDelegate: class { + func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView) +} + +class GalleryRailCellView: UIView { + + weak var delegate: GalleryRailCellViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + + layoutMargins = .zero + self.clipsToBounds = true + adjustAspectRatio(isSelected: isSelected) + addSubview(imageView) + imageView.autoPinEdgesToSuperviewMargins() + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap(sender:))) + addGestureRecognizer(tapGesture) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Actions + + @objc + func didTap(sender: UITapGestureRecognizer) { + self.delegate?.didTapGalleryRailCellView(self) + } + + // MARK: + + var item: GalleryRailItem? + + func configure(item: GalleryRailItem, delegate: GalleryRailCellViewDelegate) { + self.item = item + self.delegate = delegate + + item.getRailImage().done { image in + guard self.item === item else { return } + + self.imageView.image = image + }.retainUntilComplete() + } + + // MARK: Selected + + private(set) var isSelected: Bool = false + + func setIsSelected(_ isSelected: Bool) { + self.isSelected = isSelected + adjustAspectRatio(isSelected: isSelected) + if isSelected { + self.layoutMargins = UIEdgeInsets(top: 0, left: 3, bottom: 0, right: 3) + } else { + self.layoutMargins = .zero + } + } + + // MARK: Subview Helpers + + var aspectRatioConstraint: NSLayoutConstraint? + func adjustAspectRatio(isSelected: Bool) { + if let oldConstraint = aspectRatioConstraint { + NSLayoutConstraint.deactivate([oldConstraint]) + } + + if isSelected, let itemAspectRatio = item?.aspectRatio { + aspectRatioConstraint = imageView.autoPin(toAspectRatio: itemAspectRatio) + } else { + // Portrait mode AR by default + let kDefaultAspectRatio: CGFloat = 9.0 / 16.0 + aspectRatioConstraint = imageView.autoPin(toAspectRatio: kDefaultAspectRatio) + } + } + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + + return imageView + }() +} + +protocol GalleryRailViewDelegate: class { + func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem) +} + +class GalleryRailView: UIView, GalleryRailCellViewDelegate { + + weak var delegate: GalleryRailViewDelegate? + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(scrollView) + scrollView.layoutMargins = .zero + scrollView.autoPinEdgesToSuperviewMargins() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Public + + public func configure(itemProvider: GalleryRailItemProvider?, focusedItem: GalleryRailItem?) { + let animationDuration: TimeInterval = 0.2 + + guard let itemProvider = itemProvider else { + UIView.animate(withDuration: animationDuration) { + self.isHidden = true + } + return + } + + let areRailItemsIdentical = { (lhs: [GalleryRailItem], rhs: [GalleryRailItem]) -> Bool in + guard lhs.count == rhs.count else { + return false + } + for (index, element) in lhs.enumerated() { + guard element === rhs[index] else { + return false + } + } + return true + } + + if itemProvider === self.itemProvider, areRailItemsIdentical(itemProvider.railItems, self.cellViewItems) { + UIView.animate(withDuration: animationDuration) { + self.updateFocusedItem(focusedItem) + self.layoutIfNeeded() + } + } + + self.itemProvider = itemProvider + scrollView.subviews.forEach { $0.removeFromSuperview() } + + guard itemProvider.railItems.count > 1 else { + UIView.animate(withDuration: animationDuration) { + self.isHidden = true + } + return + } + + UIView.animate(withDuration: animationDuration) { + self.isHidden = false + } + + let cellViews = buildCellViews(items: itemProvider.railItems) + self.cellViews = cellViews + let stackView = UIStackView(arrangedSubviews: cellViews) + stackView.axis = .horizontal + stackView.spacing = 4 + + scrollView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewEdges() + stackView.autoMatch(.height, to: .height, of: scrollView) + + updateFocusedItem(focusedItem) + } + + // MARK: GalleryRailCellViewDelegate + + func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView) { + guard let item = galleryRailCellView.item else { + owsFailDebug("item was unexpectedly nil") + return + } + + delegate?.galleryRailView(self, didTapItem: item) + } + + // MARK: Subview Helpers + + private var itemProvider: GalleryRailItemProvider? + + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.isScrollEnabled = true + return scrollView + }() + + private func buildCellViews(items: [GalleryRailItem]) -> [GalleryRailCellView] { + return items.map { item in + let cellView = GalleryRailCellView() + cellView.configure(item: item, delegate: self) + return cellView + } + } + + var cellViews: [GalleryRailCellView] = [] + var cellViewItems: [GalleryRailItem] { + get { return cellViews.compactMap { $0.item } } + } + func updateFocusedItem(_ focusedItem: GalleryRailItem?) { + var selectedCellView: GalleryRailCellView? + cellViews.forEach { cellView in + if cellView.item === focusedItem { + assert(selectedCellView == nil) + selectedCellView = cellView + cellView.setIsSelected(true) + } else { + cellView.setIsSelected(false) + } + } + + self.layoutIfNeeded() + guard let selectedCell = selectedCellView else { + owsFailDebug("selectedCell was unexpectedly nil") + return + } + + let cellViewCenter = selectedCell.superview!.convert(selectedCell.center, to: scrollView) + let additionalInset = scrollView.center.x - cellViewCenter.x + + var inset = scrollView.contentInset + inset.left = additionalInset + scrollView.contentInset = inset + + var offset = scrollView.contentOffset + offset.x = -additionalInset + scrollView.contentOffset = offset + } +}