Swiftgram/submodules/ContextUI/Sources/ContextSourceContainer.swift
2024-12-18 23:34:33 +08:00

819 lines
33 KiB
Swift

import Foundation
import AsyncDisplayKit
import Display
import TelegramPresentationData
import SwiftSignalKit
import TelegramCore
import ReactionSelectionNode
import ComponentFlow
import TabSelectorComponent
import PlainButtonComponent
import ComponentDisplayAdapters
import AccountContext
final class ContextSourceContainer: ASDisplayNode {
final class Source {
weak var controller: ContextController?
let id: AnyHashable
let title: String
let context: AccountContext?
let source: ContextContentSource
let closeActionTitle: String?
let closeAction: (() -> Void)?
private var _presentationNode: ContextControllerPresentationNode?
var presentationNode: ContextControllerPresentationNode {
return self._presentationNode!
}
var currentPresentationStateTransition: ContextControllerPresentationNodeStateTransition?
var validLayout: ContainerViewLayout?
var presentationData: PresentationData?
var delayLayoutUpdate: Bool = false
var isAnimatingOut: Bool = false
var itemsDisposables = DisposableSet()
let ready = Promise<Bool>()
private let contentReady = Promise<Bool>()
private let actionsReady = Promise<Bool>()
init(
controller: ContextController,
id: AnyHashable,
title: String,
context: AccountContext?,
source: ContextContentSource,
items: Signal<ContextController.Items, NoError>,
closeActionTitle: String? = nil,
closeAction: (() -> Void)? = nil
) {
self.controller = controller
self.id = id
self.title = title
self.context = context
self.source = source
self.closeActionTitle = closeActionTitle
self.closeAction = closeAction
self.ready.set(combineLatest(queue: .mainQueue(), self.contentReady.get(), self.actionsReady.get())
|> map { a, b -> Bool in
return a && b
}
|> distinctUntilChanged)
switch source {
case let .location(source):
self.contentReady.set(.single(true))
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .location(source)
)
self._presentationNode = presentationNode
case let .reference(source):
self.contentReady.set(.single(true))
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .reference(source)
)
self._presentationNode = presentationNode
case let .extracted(source):
self.contentReady.set(.single(true))
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
if let _ = self.closeActionTitle {
} else {
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
}
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .extracted(source)
)
self._presentationNode = presentationNode
case let .controller(source):
self.contentReady.set(source.controller.ready.get())
let presentationNode = ContextControllerExtractedPresentationNode(
context: self.context,
getController: { [weak self] in
guard let self else {
return nil
}
return self.controller
},
requestUpdate: { [weak self] transition in
guard let self else {
return
}
self.update(transition: transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in
guard let self else {
return
}
if let controller = self.controller {
controller.overlayWantsToBeBelowKeyboardUpdated(transition: transition)
}
},
requestDismiss: { [weak self] result in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.dismissedForCancel?()
controller.controllerNode.beginDismiss(result)
},
requestAnimateOut: { [weak self] result, completion in
guard let self, let controller = self.controller else {
return
}
controller.controllerNode.animateOut(result: result, completion: completion)
},
source: .controller(source)
)
self._presentationNode = presentationNode
}
self.itemsDisposables.add((items |> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
self.setItems(items: items, animated: nil)
self.actionsReady.set(.single(true))
}))
}
deinit {
self.itemsDisposables.dispose()
}
func animateIn() {
self.currentPresentationStateTransition = .animateIn
self.update(transition: .animated(duration: 0.5, curve: .spring))
}
func animateOut(result: ContextMenuActionResult, completion: @escaping () -> Void) {
self.currentPresentationStateTransition = .animateOut(result: result, completion: completion)
if let _ = self.validLayout {
if case let .custom(transition) = result {
self.delayLayoutUpdate = true
Queue.mainQueue().after(0.1) {
self.delayLayoutUpdate = false
self.update(transition: transition)
self.isAnimatingOut = true
}
} else {
self.update(transition: .animated(duration: 0.35, curve: .easeInOut))
}
}
}
func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
self.presentationNode.addRelativeContentOffset(offset, transition: transition)
}
func cancelReactionAnimation() {
self.presentationNode.cancelReactionAnimation()
}
func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, onHit: (() -> Void)?, completion: @escaping () -> Void) {
self.presentationNode.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, onHit: onHit, completion: completion)
}
func setItems(items: Signal<ContextController.Items, NoError>, animated: Bool) {
self.itemsDisposables.dispose()
self.itemsDisposables = DisposableSet()
self.itemsDisposables.add((items
|> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
self.setItems(items: items, animated: animated)
}))
}
func setItems(items: ContextController.Items, animated: Bool?) {
self.presentationNode.replaceItems(items: items, animated: animated)
}
func pushItems(items: Signal<ContextController.Items, NoError>) {
self.itemsDisposables.add((items
|> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
self.presentationNode.pushItems(items: items)
}))
}
func popItems() {
self.itemsDisposables.removeLast()
self.presentationNode.popItems()
}
func update(transition: ContainedViewLayoutTransition) {
guard let validLayout = self.validLayout else {
return
}
guard let presentationData = self.presentationData else {
return
}
self.update(presentationData: presentationData, layout: validLayout, transition: transition)
}
func update(
presentationData: PresentationData,
layout: ContainerViewLayout,
transition: ContainedViewLayoutTransition
) {
if self.isAnimatingOut || self.delayLayoutUpdate {
return
}
self.validLayout = layout
self.presentationData = presentationData
let presentationStateTransition = self.currentPresentationStateTransition
self.currentPresentationStateTransition = .none
self.presentationNode.update(
presentationData: presentationData,
layout: layout,
transition: transition,
stateTransition: presentationStateTransition
)
}
}
private struct PanState {
var fraction: CGFloat
init(fraction: CGFloat) {
self.fraction = fraction
}
}
private weak var controller: ContextController?
private let backgroundNode: NavigationBackgroundNode
var sources: [Source] = []
var activeIndex: Int = 0
private var tabSelector: ComponentView<Empty>?
private var closeButton: ComponentView<Empty>?
private var presentationData: PresentationData?
private var validLayout: ContainerViewLayout?
private var panState: PanState?
let ready = Promise<Bool>()
var activeSource: Source? {
if self.activeIndex >= self.sources.count {
return nil
}
return self.sources[self.activeIndex]
}
var overlayWantsToBeBelowKeyboard: Bool {
return self.activeSource?.presentationNode.wantsDisplayBelowKeyboard() ?? false
}
init(controller: ContextController, configuration: ContextController.Configuration, context: AccountContext?) {
self.controller = controller
self.backgroundNode = NavigationBackgroundNode(color: .clear, enableBlur: false)
super.init()
self.addSubnode(self.backgroundNode)
for i in 0 ..< configuration.sources.count {
let source = configuration.sources[i]
let mappedSource = Source(
controller: controller,
id: source.id,
title: source.title,
context: context,
source: source.source,
items: source.items,
closeActionTitle: source.closeActionTitle,
closeAction: source.closeAction
)
self.sources.append(mappedSource)
self.addSubnode(mappedSource.presentationNode)
if source.id == configuration.initialId {
self.activeIndex = i
}
}
self.ready.set(self.sources[self.activeIndex].ready.get())
self.view.addGestureRecognizer(InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] _ in
guard let self else {
return []
}
if self.sources.count <= 1 {
return []
}
return [.left, .right]
}))
}
@objc private func panGesture(_ recognizer: InteractiveTransitionGestureRecognizer) {
switch recognizer.state {
case .began, .changed:
if let validLayout = self.validLayout {
var translationX = recognizer.translation(in: self.view).x
if self.activeIndex == 0 && translationX > 0.0 {
translationX = scrollingRubberBandingOffset(offset: abs(translationX), bandingStart: 0.0, range: 20.0)
} else if self.activeIndex == self.sources.count - 1 && translationX < 0.0 {
translationX = -scrollingRubberBandingOffset(offset: abs(translationX), bandingStart: 0.0, range: 20.0)
}
self.panState = PanState(fraction: translationX / validLayout.size.width)
self.update(transition: .immediate)
}
case .cancelled, .ended:
if let panState = self.panState {
self.panState = nil
let velocity = recognizer.velocity(in: self.view)
var nextIndex = self.activeIndex
if panState.fraction < -0.4 {
nextIndex += 1
} else if panState.fraction > 0.4 {
nextIndex -= 1
} else if abs(velocity.x) >= 200.0 {
if velocity.x < 0.0 {
nextIndex += 1
} else {
nextIndex -= 1
}
}
if nextIndex < 0 {
nextIndex = 0
}
if nextIndex > self.sources.count - 1 {
nextIndex = self.sources.count - 1
}
if nextIndex != self.activeIndex {
self.activeIndex = nextIndex
}
self.update(transition: .animated(duration: 0.4, curve: .spring))
}
default:
break
}
}
func animateIn() {
self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
if let activeSource = self.activeSource {
activeSource.animateIn()
}
if let tabSelectorView = self.tabSelector?.view {
tabSelectorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
if let closeButtonView = self.closeButton?.view {
closeButtonView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
func animateOut(result: ContextMenuActionResult, completion: @escaping () -> Void) {
let delayDismissal = self.activeSource?.closeAction != nil
let delay: Double = delayDismissal ? 0.2 : 0.0
let duration: Double = delayDismissal ? 0.35 : 0.2
self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, delay: delay, removeOnCompletion: false, completion: { _ in
if delayDismissal {
Queue.mainQueue().after(0.55) {
completion()
}
}
})
if let tabSelectorView = self.tabSelector?.view {
tabSelectorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, delay: delay, removeOnCompletion: false)
}
if let closeButtonView = self.closeButton?.view {
closeButtonView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, delay: delay, removeOnCompletion: false)
}
if let activeSource = self.activeSource {
activeSource.animateOut(result: result, completion: delayDismissal ? {} : completion)
} else {
completion()
}
}
func highlightGestureMoved(location: CGPoint, hover: Bool) {
if self.activeIndex >= self.sources.count {
return
}
self.sources[self.activeIndex].presentationNode.highlightGestureMoved(location: location, hover: hover)
}
func highlightGestureFinished(performAction: Bool) {
if self.activeIndex >= self.sources.count {
return
}
self.sources[self.activeIndex].presentationNode.highlightGestureFinished(performAction: performAction)
}
func performHighlightedAction() {
self.activeSource?.presentationNode.highlightGestureFinished(performAction: true)
}
func decreaseHighlightedIndex() {
self.activeSource?.presentationNode.decreaseHighlightedIndex()
}
func increaseHighlightedIndex() {
self.activeSource?.presentationNode.increaseHighlightedIndex()
}
func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) {
if let activeSource = self.activeSource {
activeSource.addRelativeContentOffset(offset, transition: transition)
}
}
func cancelReactionAnimation() {
if let activeSource = self.activeSource {
activeSource.cancelReactionAnimation()
}
}
func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, reducedCurve: Bool, onHit: (() -> Void)?, completion: @escaping () -> Void) {
if let activeSource = self.activeSource {
activeSource.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, animateTargetContainer: animateTargetContainer, addStandaloneReactionAnimation: addStandaloneReactionAnimation, reducedCurve: reducedCurve, onHit: onHit, completion: completion)
} else {
completion()
}
}
func setItems(items: Signal<ContextController.Items, NoError>, animated: Bool) {
if let activeSource = self.activeSource {
activeSource.setItems(items: items, animated: animated)
}
}
func pushItems(items: Signal<ContextController.Items, NoError>) {
if let activeSource = self.activeSource {
activeSource.pushItems(items: items)
}
}
func popItems() {
if let activeSource = self.activeSource {
activeSource.popItems()
}
}
private func update(transition: ContainedViewLayoutTransition) {
if let presentationData = self.presentationData, let validLayout = self.validLayout {
self.update(presentationData: presentationData, layout: validLayout, transition: transition)
}
}
func update(
presentationData: PresentationData,
layout: ContainerViewLayout,
transition: ContainedViewLayoutTransition
) {
self.presentationData = presentationData
self.validLayout = layout
var childLayout = layout
if let activeSource = self.activeSource {
switch activeSource.source {
case .location, .reference:
self.backgroundNode.updateColor(
color: .clear,
enableBlur: false,
forceKeepBlur: false,
transition: .immediate
)
case .extracted:
self.backgroundNode.updateColor(
color: presentationData.theme.contextMenu.dimColor,
enableBlur: true,
forceKeepBlur: true,
transition: .immediate
)
case .controller:
if case .regular = layout.metrics.widthClass {
self.backgroundNode.updateColor(
color: UIColor(white: 0.0, alpha: 0.4),
enableBlur: false,
forceKeepBlur: false,
transition: .immediate
)
} else {
self.backgroundNode.updateColor(
color: presentationData.theme.contextMenu.dimColor,
enableBlur: true,
forceKeepBlur: true,
transition: .immediate
)
}
}
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: layout.size), beginWithCurrentState: true)
self.backgroundNode.update(size: layout.size, transition: transition)
if self.sources.count > 1 {
let tabSelector: ComponentView<Empty>
if let current = self.tabSelector {
tabSelector = current
} else {
tabSelector = ComponentView()
self.tabSelector = tabSelector
}
let mappedItems = self.sources.map { source -> TabSelectorComponent.Item in
return TabSelectorComponent.Item(id: source.id, title: source.title)
}
let tabSelectorSize = tabSelector.update(
transition: ComponentTransition(transition),
component: AnyComponent(TabSelectorComponent(
colors: TabSelectorComponent.Colors(
foreground: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.8),
selection: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1)
),
customLayout: TabSelectorComponent.CustomLayout(
font: Font.medium(14.0),
spacing: 9.0
),
items: mappedItems,
selectedId: self.activeSource?.id,
setSelectedId: { [weak self] id in
guard let self else {
return
}
if let index = self.sources.firstIndex(where: { $0.id == id }) {
self.activeIndex = index
self.update(transition: .animated(duration: 0.4, curve: .spring))
}
}
)),
environment: {},
containerSize: CGSize(width: layout.size.width, height: 44.0)
)
childLayout.intrinsicInsets.bottom += 30.0
if let tabSelectorView = tabSelector.view {
if tabSelectorView.superview == nil {
self.view.addSubview(tabSelectorView)
}
transition.updateFrame(view: tabSelectorView, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - tabSelectorSize.width) * 0.5), y: layout.size.height - layout.intrinsicInsets.bottom - tabSelectorSize.height), size: tabSelectorSize))
}
} else if let source = self.sources.first, let closeActionTitle = source.closeActionTitle {
let closeButton: ComponentView<Empty>
if let current = self.closeButton {
closeButton = current
} else {
closeButton = ComponentView()
self.closeButton = closeButton
}
let closeButtonSize = closeButton.update(
transition: ComponentTransition(transition),
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(
CloseButtonComponent(
backgroundColor: presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.1),
text: closeActionTitle
)
),
effectAlignment: .center,
action: { [weak self, weak source] in
guard let self else {
return
}
if let source, let closeAction = source.closeAction {
closeAction()
} else {
self.controller?.dismiss(result: .dismissWithoutContent, completion: nil)
}
})
),
environment: {},
containerSize: CGSize(width: layout.size.width, height: 44.0)
)
childLayout.intrinsicInsets.bottom += 30.0
if let closeButtonView = closeButton.view {
if closeButtonView.superview == nil {
self.view.addSubview(closeButtonView)
}
transition.updateFrame(view: closeButtonView, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - closeButtonSize.width) * 0.5), y: layout.size.height - layout.intrinsicInsets.bottom - closeButtonSize.height - 10.0), size: closeButtonSize))
}
} else if let tabSelector = self.tabSelector {
self.tabSelector = nil
tabSelector.view?.removeFromSuperview()
}
for i in 0 ..< self.sources.count {
var itemFrame = CGRect(origin: CGPoint(), size: childLayout.size)
itemFrame.origin.x += CGFloat(i - self.activeIndex) * childLayout.size.width
if let panState = self.panState {
itemFrame.origin.x += panState.fraction * childLayout.size.width
}
let itemTransition = transition
itemTransition.updateFrame(node: self.sources[i].presentationNode, frame: itemFrame)
self.sources[i].update(
presentationData: presentationData,
layout: childLayout,
transition: itemTransition
)
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let tabSelectorView = self.tabSelector?.view {
if let result = tabSelectorView.hitTest(self.view.convert(point, to: tabSelectorView), with: event) {
return result
}
}
if let closeButtonView = self.closeButton?.view {
if let result = closeButtonView.hitTest(self.view.convert(point, to: closeButtonView), with: event) {
return result
}
}
guard let activeSource = self.activeSource else {
return nil
}
return activeSource.presentationNode.view.hitTest(point, with: event)
}
}
private final class CloseButtonComponent: CombinedComponent {
let backgroundColor: UIColor
let text: String
init(
backgroundColor: UIColor,
text: String
) {
self.backgroundColor = backgroundColor
self.text = text
}
static func ==(lhs: CloseButtonComponent, rhs: CloseButtonComponent) -> Bool {
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.text != rhs.text {
return false
}
return true
}
static var body: Body {
let background = Child(RoundedRectangle.self)
let text = Child(Text.self)
return { context in
let text = text.update(
component: Text(
text: "\(context.component.text)",
font: Font.regular(17.0),
color: .white
),
availableSize: CGSize(width: 200.0, height: 100.0),
transition: .immediate
)
let backgroundSize = CGSize(width: text.size.width + 34.0, height: 36.0)
let background = background.update(
component: RoundedRectangle(color: context.component.backgroundColor, cornerRadius: 18.0),
availableSize: backgroundSize,
transition: .immediate
)
context.add(background
.position(CGPoint(x: backgroundSize.width / 2.0, y: backgroundSize.height / 2.0))
)
context.add(text
.position(CGPoint(x: backgroundSize.width / 2.0, y: backgroundSize.height / 2.0))
)
return backgroundSize
}
}
}