Album rail in Gallery

This commit is contained in:
Michael Kirk 2018-11-13 17:02:48 -06:00
parent fd424f3892
commit 84879b991d
4 changed files with 422 additions and 19 deletions

View File

@ -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 = "<group>"; };
4C1885CF218D0EA800B67051 /* ImagePickerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = "<group>"; };
4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridViewCell.swift; sourceTree = "<group>"; };
4C1D233C218B96A000A0598F /* typing-animation.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; name = "typing-animation.gif"; path = "../../../../../Downloads/typing-animation.gif"; sourceTree = "<group>"; };
4C1D2333218B692800A0598F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = translations/ko.lproj/Localizable.strings; sourceTree = "<group>"; };
4C1D2334218B6A1100A0598F /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = translations/az.lproj/Localizable.strings; sourceTree = "<group>"; };
4C1D2335218B6A7600A0598F /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = translations/el.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -1159,6 +1159,7 @@
4C858A51212DC5E1001B45D3 /* UIImage+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+OWS.swift"; sourceTree = "<group>"; };
4C948FF62146EB4800349F0D /* BlockListCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListCache.swift; sourceTree = "<group>"; };
4C9CA25C217E676900607C63 /* ZXingObjC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZXingObjC.framework; path = ThirdParty/Carthage/Build/iOS/ZXingObjC.framework; sourceTree = "<group>"; };
4CA46F49219C78050038ABDE /* GalleryRailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRailView.swift; sourceTree = "<group>"; };
4CA5F792211E1F06008C2708 /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = "<group>"; };
4CB5F26820F7D060004D1B42 /* MessageActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActions.swift; sourceTree = "<group>"; };
4CB93DC12180FF07004B9764 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitoringManager.swift; sourceTree = "<group>"; };
@ -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 */,

View File

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

View File

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

View File

@ -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<UIImage>
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<UIImage> {
let (guarantee, fulfill) = Guarantee<UIImage>.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
}
}