Swiftgram/submodules/MediaPickerUI/Sources/MediaGroupsScreen.swift
2024-11-27 18:41:03 +04:00

488 lines
22 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import Photos
import LegacyComponents
import AttachmentUI
import ItemListUI
import MediaAssetsContext
private enum MediaGroupsEntry: Comparable, Identifiable {
enum StableId: Hashable {
case albumsHeader
case albums
case smartAlbumsHeader
case smartAlbum(String)
}
case albumsHeader(PresentationTheme, String)
case albums(PresentationTheme, [PHAssetCollection])
case smartAlbumsHeader(PresentationTheme, String)
case smartAlbum(PresentationTheme, Int, PHAssetCollection, Int)
var stableId: StableId {
switch self {
case .albumsHeader:
return .albumsHeader
case .albums:
return .albums
case .smartAlbumsHeader:
return .smartAlbumsHeader
case let .smartAlbum(_, _, album, _):
return .smartAlbum(album.localIdentifier)
}
}
static func ==(lhs: MediaGroupsEntry, rhs: MediaGroupsEntry) -> Bool {
switch lhs {
case let .albumsHeader(lhsTheme, lhsText):
if case let .albumsHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
return true
} else {
return false
}
case let .albums(lhsTheme, lhsAssetCollections):
if case let .albums(rhsTheme, rhsAssetCollections) = rhs, lhsTheme === rhsTheme, lhsAssetCollections == rhsAssetCollections {
return true
} else {
return false
}
case let .smartAlbumsHeader(lhsTheme, lhsText):
if case let .smartAlbumsHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
return true
} else {
return false
}
case let .smartAlbum(lhsTheme, lhsIndex, lhsAssetCollection, lhsCount):
if case let .smartAlbum(rhsTheme, rhsIndex, rhsAssetCollection, rhsCount) = rhs, lhsTheme === rhsTheme, lhsIndex == rhsIndex, lhsAssetCollection == rhsAssetCollection, lhsCount == rhsCount {
return true
} else {
return false
}
}
}
private var sortId: Int {
switch self {
case .albumsHeader:
return 0
case .albums:
return 1
case .smartAlbumsHeader:
return 2
case let .smartAlbum(_, index, _, _):
return 3 + index
}
}
static func <(lhs: MediaGroupsEntry, rhs: MediaGroupsEntry) -> Bool {
return lhs.sortId < rhs.sortId
}
func item(presentationData: PresentationData, openGroup: @escaping (PHAssetCollection) -> Void) -> ListViewItem {
switch self {
case let .albumsHeader(_, text), let .smartAlbumsHeader(_, text):
return MediaGroupsHeaderItem(presentationData: ItemListPresentationData(presentationData), title: text)
case let .albums(_, collections):
return MediaGroupsAlbumGridItem(presentationData: presentationData, collections: collections, action: { collection in
openGroup(collection)
})
case let .smartAlbum(_, _, collection, count):
let title = collection.localizedTitle ?? ""
let count = presentationStringsFormattedNumber(Int32(count), presentationData.dateTimeFormat.groupingSeparator)
var icon: MediaGroupsAlbumItem.Icon?
switch collection.assetCollectionSubtype {
case .smartAlbumAnimated:
icon = .animated
case .smartAlbumBursts:
icon = .bursts
case .smartAlbumDepthEffect:
icon = .depthEffect
case .smartAlbumLivePhotos:
icon = .livePhotos
case .smartAlbumPanoramas:
icon = .panoramas
case .smartAlbumScreenshots:
icon = .screenshots
case .smartAlbumSelfPortraits:
icon = .selfPortraits
case .smartAlbumSlomoVideos:
icon = .slomoVideos
case .smartAlbumTimelapses:
icon = .timelapses
case .smartAlbumVideos:
icon = .videos
case .smartAlbumAllHidden:
icon = .hidden
default:
icon = nil
}
return MediaGroupsAlbumItem(presentationData: ItemListPresentationData(presentationData), title: title, count: count, icon: icon, action: {
openGroup(collection)
})
}
}
}
private struct MediaGroupsTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
}
private func preparedTransition(from fromEntries: [MediaGroupsEntry], to toEntries: [MediaGroupsEntry], presentationData: PresentationData, openGroup: @escaping (PHAssetCollection) -> Void) -> MediaGroupsTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, openGroup: openGroup), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, openGroup: openGroup), directionHint: nil) }
return MediaGroupsTransition(deletions: deletions, insertions: insertions, updates: updates)
}
public final class MediaGroupsScreen: ViewController, AttachmentContainable {
public var requestAttachmentMenuExpansion: () -> Void = {}
public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in }
public var parentController: () -> ViewController? = {
return nil
}
public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in }
public var updateTabBarVisibility: (Bool, ContainedViewLayoutTransition) -> Void = { _, _ in }
public var cancelPanGesture: () -> Void = { }
public var isContainerPanning: () -> Bool = { return false }
public var isContainerExpanded: () -> Bool = { return false }
public var isMinimized: Bool = false
public var mediaPickerContext: AttachmentMediaPickerContext? {
return nil
}
private let context: AccountContext
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private let mediaAssetsContext: MediaAssetsContext
private let embedded: Bool
private let openGroup: (PHAssetCollection) -> Void
private class Node: ViewControllerTracingNode {
struct State {
let albums: PHFetchResult<PHAssetCollection>
let smartAlbums: PHFetchResult<PHAssetCollection>
}
private weak var controller: MediaGroupsScreen?
private var presentationData: PresentationData
private let containerNode: ASDisplayNode
private let backgroundNode: NavigationBackgroundNode
private let listNode: ListView
private var nextStableId: Int = 1
private var currentEntries: [MediaGroupsEntry] = []
private var enqueuedTransactions: [MediaGroupsTransition] = []
private var state: State?
private var itemsDisposable: Disposable?
private var didSetReady = false
private let _ready = Promise<Bool>()
var ready: Promise<Bool> {
return self._ready
}
private var validLayout: (ContainerViewLayout, CGFloat)?
init(controller: MediaGroupsScreen) {
self.controller = controller
self.presentationData = controller.presentationData
self.containerNode = ASDisplayNode()
self.backgroundNode = NavigationBackgroundNode(color: self.presentationData.theme.rootController.tabBar.backgroundColor)
self.listNode = ListView()
super.init()
if !controller.embedded {
self.addSubnode(self.backgroundNode)
} else {
self.containerNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
}
self.addSubnode(self.containerNode)
self.containerNode.addSubnode(self.listNode)
let updatedState = combineLatest(queue: Queue.mainQueue(), controller.mediaAssetsContext.fetchAssetsCollections(.album), controller.mediaAssetsContext.fetchAssetsCollections(.smartAlbum))
self.itemsDisposable = (updatedState
|> deliverOnMainQueue).start(next: { [weak self] albums, smartAlbums in
guard let strongSelf = self else {
return
}
strongSelf.updateState(State(albums: albums, smartAlbums: smartAlbums))
})
self.listNode.beganInteractiveDragging = { [weak self] _ in
self?.view.window?.endEditing(true)
}
self.listNode.visibleContentOffsetChanged = { [weak self] _ in
self?.updateNavigation(transition: .immediate)
}
}
deinit {
self.itemsDisposable?.dispose()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if result == self.view {
return nil
}
return result
}
private func updateState(_ state: State) {
self.state = state
var entries: [MediaGroupsEntry] = []
var albums: [PHAssetCollection] = []
entries.append(.albumsHeader(self.presentationData.theme, self.presentationData.strings.Attachment_MyAlbums))
state.smartAlbums.enumerateObjects { collection, _, _ in
if [.smartAlbumUserLibrary, .smartAlbumFavorites].contains(collection.assetCollectionSubtype) {
albums.append(collection)
}
}
state.albums.enumerateObjects(options: [.reverse]) { collection, _, _ in
albums.append(collection)
}
entries.append(.albums(self.presentationData.theme, albums))
let smartAlbumsHeaderIndex = entries.count
var addedSmartAlbum = false
state.smartAlbums.enumerateObjects { collection, index, _ in
var supportedAlbums: [PHAssetCollectionSubtype] = [
.smartAlbumBursts,
.smartAlbumPanoramas,
.smartAlbumScreenshots,
.smartAlbumSelfPortraits,
.smartAlbumSlomoVideos,
.smartAlbumTimelapses,
.smartAlbumVideos,
.smartAlbumAllHidden
]
if #available(iOS 11, *) {
supportedAlbums.append(.smartAlbumAnimated)
supportedAlbums.append(.smartAlbumDepthEffect)
supportedAlbums.append(.smartAlbumLivePhotos)
}
if supportedAlbums.contains(collection.assetCollectionSubtype) {
let result = PHAsset.fetchAssets(in: collection, options: nil)
if result.count > 0 {
addedSmartAlbum = true
entries.append(.smartAlbum(self.presentationData.theme, index, collection, result.count))
}
}
}
if addedSmartAlbum {
entries.insert(.smartAlbumsHeader(self.presentationData.theme, self.presentationData.strings.Attachment_MediaTypes), at: smartAlbumsHeaderIndex)
}
let previousEntries = self.currentEntries
self.currentEntries = entries
let transaction = preparedTransition(from: previousEntries, to: entries, presentationData: self.presentationData, openGroup: { [weak self] collection in
self?.view.window?.endEditing(true)
self?.controller?.openGroup(collection)
})
self.enqueueTransaction(transaction)
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
if self.controller?.embedded == true {
self.containerNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
}
self.backgroundNode.updateColor(color: self.presentationData.theme.rootController.tabBar.backgroundColor, transition: .immediate)
}
private func enqueueTransaction(_ transaction: MediaGroupsTransition) {
self.enqueuedTransactions.append(transaction)
if let _ = self.validLayout {
while !self.enqueuedTransactions.isEmpty {
self.dequeueTransaction()
}
}
}
private func dequeueTransaction() {
if self.enqueuedTransactions.isEmpty {
return
}
let transaction = self.enqueuedTransactions.removeFirst()
let options = ListViewDeleteAndInsertOptions()
self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
if let strongSelf = self {
if !strongSelf.didSetReady {
strongSelf.didSetReady = true
strongSelf._ready.set(.single(true))
}
}
})
}
func scrollToTop() {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
guard let controller = self.controller else {
return
}
let firstTime = self.validLayout == nil
self.validLayout = (layout, navigationBarHeight)
let topInset: CGFloat = controller.embedded ? 12.0 : 0.0
let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight + topInset), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight - topInset))
transition.updateFrame(node: self.containerNode, frame: containerFrame)
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: .zero, size: layout.size))
self.backgroundNode.update(size: layout.size, transition: transition)
let size = layout.size
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: UIEdgeInsets(top: 0.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom + 56.0, right: layout.safeInsets.right), headerInsets: UIEdgeInsets(), scrollIndicatorInsets: UIEdgeInsets(), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size))
if firstTime {
self.dequeueTransaction()
}
}
private var previousContentOffset: GridNodeVisibleContentOffset?
func updateNavigation(delayDisappear: Bool = false, transition: ContainedViewLayoutTransition) {
var previousContentOffsetValue: CGFloat?
if let previousContentOffset = self.previousContentOffset, case let .known(value) = previousContentOffset {
previousContentOffsetValue = value
}
let offset = self.listNode.visibleContentOffset()
switch offset {
case let .known(value):
let transition: ContainedViewLayoutTransition
if let previousContentOffsetValue = previousContentOffsetValue, value <= 0.0, previousContentOffsetValue > 2.0 {
transition = .animated(duration: 0.2, curve: .easeInOut)
} else {
transition = .immediate
}
self.controller?.navigationBar?.updateBackgroundAlpha(min(2.0, value) / 2.0, transition: transition)
case .unknown, .none:
self.controller?.navigationBar?.updateBackgroundAlpha(1.0, transition: .immediate)
}
}
}
private var validLayout: ContainerViewLayout?
private var controllerNode: Node {
return self.displayNode as! Node
}
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, mediaAssetsContext: MediaAssetsContext, embedded: Bool = false, openGroup: @escaping (PHAssetCollection) -> Void) {
self.context = context
self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
self.mediaAssetsContext = mediaAssetsContext
self.embedded = embedded
self.openGroup = openGroup
super.init(navigationBarPresentationData: !embedded ? NavigationBarPresentationData(presentationData: presentationData) : nil)
self.statusBar.statusBarStyle = .Ignore
self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData)
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
let previousTheme = strongSelf.presentationData.theme
let previousStrings = strongSelf.presentationData.strings
strongSelf.presentationData = presentationData
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
strongSelf.controllerNode.updatePresentationData(presentationData)
}
}
})
self.scrollToTop = { [weak self] in
if let strongSelf = self {
strongSelf.controllerNode.scrollToTop()
}
}
if !embedded {
self.title = "Albums"
self.navigationItem.leftBarButtonItem = UIBarButtonItem(backButtonAppearanceWithTitle: self.presentationData.strings.Common_Back, target: self, action: #selector(self.backPressed))
}
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = Node(controller: self)
self._ready.set(self.controllerNode.ready.get())
super.displayNodeDidLoad()
}
@objc private func backPressed() {
if let _ = self.navigationController {
self.dismiss()
} else {
self.updateNavigationStack { current in
var mediaPickerContext: AttachmentMediaPickerContext?
if let first = current.first as? MediaPickerScreenImpl {
mediaPickerContext = first.webSearchController?.mediaPickerContext ?? first.mediaPickerContext
}
return (current.filter { $0 !== self }, mediaPickerContext)
}
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.validLayout = layout
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
}