From 2b1d09fb5a2b16168f1971349c94706b6ca71c43 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 25 Jun 2024 22:26:55 +0400 Subject: [PATCH] Web app minimization --- .../Telegram-iOS/en.lproj/Localizable.strings | 8 +- submodules/AttachmentUI/BUILD | 1 + .../Sources/AttachmentContainer.swift | 19 +- .../Sources/AttachmentController.swift | 63 +- .../Sources/BrowserStackContainerNode.swift | 445 --------- .../ContainedViewLayoutTransition.swift | 13 +- .../Navigation/MinimizedContainer.swift | 13 + .../Navigation/NavigationController.swift | 162 +-- .../Source/Navigation/NavigationLayout.swift | 23 +- .../Navigation/NavigationModalContainer.swift | 202 +--- .../Display/Source/ViewController.swift | 3 +- .../ChatMessageStarsMediaInfoNode.swift | 5 +- .../Sources/ChatMessageUnlockMediaNode.swift | 3 +- .../Components/MinimizedContainer/BUILD | 23 + .../Sources/HeaderNode.swift | 142 +++ .../Sources/MinimizedContainer.swift | 926 ++++++++++++++++++ .../MinimizedContainer/Sources/Utils.swift | 52 + .../Sources/StarsPurchaseScreen.swift | 8 +- .../Sources/StarsTransferScreen.swift | 17 +- .../TelegramUI/Sources/ChatController.swift | 12 +- .../WebUI/Sources/WebAppController.swift | 6 +- 21 files changed, 1386 insertions(+), 760 deletions(-) delete mode 100644 submodules/BrowserUI/Sources/BrowserStackContainerNode.swift create mode 100644 submodules/Display/Source/Navigation/MinimizedContainer.swift create mode 100644 submodules/TelegramUI/Components/MinimizedContainer/BUILD create mode 100644 submodules/TelegramUI/Components/MinimizedContainer/Sources/HeaderNode.swift create mode 100644 submodules/TelegramUI/Components/MinimizedContainer/Sources/MinimizedContainer.swift create mode 100644 submodules/TelegramUI/Components/MinimizedContainer/Sources/Utils.swift diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index bc3129ff72..4625f9d971 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -12284,6 +12284,7 @@ Sorry for the inconvenience."; "Stars.Purchase.StarsNeeded_1" = "%@ Star Needed"; "Stars.Purchase.StarsNeeded_any" = "%@ Stars Needed"; "Stars.Purchase.StarsNeededInfo" = "Buy Stars to use them on **%@** and other miniapps."; +"Stars.Purchase.StarsNeededUnlockInfo" = "Buy Stars to unlock media and use them on miniapps."; "Stars.Purchase.Stars_1" = "%@ Star"; "Stars.Purchase.Stars_any" = "%@ Stars"; @@ -12453,10 +12454,15 @@ Sorry for the inconvenience."; "Premium.MessageEffects" = "Message Effects"; "Premium.MessageEffectsInfo" = "Add over 500 animated effects to private messages."; -"Chat.UnlockMedia" = "Unlock for %@"; +"Chat.PaidMedia.UnlockMedia" = "Unlock for %@"; +"Chat.PaidMedia.Purchased" = "Purchased"; "Attachment.SendWithoutGrouping" = "Send Without Grouping"; "Attachment.Paid.EditPrice" = "Edit Price"; "Attachment.Paid.EditPrice.Stars_1" = "%@ Star"; "Attachment.Paid.EditPrice.Stars_any" = "%@ Stars"; "Attachment.Paid.Create" = "Make This Content Paid"; + +"WebApp.MinimizedTitleFormat" = "%1$@ & %2$@"; +"WebApp.MinimizedTitle.Others_1" = "%@ Other"; +"WebApp.MinimizedTitle.Others_any" = "%@ Others"; diff --git a/submodules/AttachmentUI/BUILD b/submodules/AttachmentUI/BUILD index 107d258c74..9a116297b8 100644 --- a/submodules/AttachmentUI/BUILD +++ b/submodules/AttachmentUI/BUILD @@ -41,6 +41,7 @@ swift_library( "//submodules/TelegramUI/Components/LegacyMessageInputPanelInputView", "//submodules/ReactionSelectionNode", "//submodules/TelegramUI/Components/Chat/TopMessageReactions", + "//submodules/TelegramUI/Components/MinimizedContainer", ], visibility = [ "//visibility:public", diff --git a/submodules/AttachmentUI/Sources/AttachmentContainer.swift b/submodules/AttachmentUI/Sources/AttachmentContainer.swift index 898cd2b5fe..04097a26c8 100644 --- a/submodules/AttachmentUI/Sources/AttachmentContainer.swift +++ b/submodules/AttachmentUI/Sources/AttachmentContainer.swift @@ -34,13 +34,13 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate { private(set) var dismissProgress: CGFloat = 0.0 var isReadyUpdated: (() -> Void)? var updateDismissProgress: ((CGFloat, ContainedViewLayoutTransition) -> Void)? - var interactivelyDismissed: (() -> Bool)? + var interactivelyDismissed: ((CGFloat) -> Bool)? var controllerRemoved: ((ViewController) -> Void)? var shouldCancelPanGesture: (() -> Bool)? var requestDismiss: (() -> Void)? - var updateModalProgress: ((CGFloat, CGFloat, ContainedViewLayoutTransition) -> Void)? + var updateModalProgress: ((CGFloat, CGFloat, CGRect, ContainedViewLayoutTransition) -> Void)? private var isUpdatingState = false private var isDismissed = false @@ -306,10 +306,13 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate { ignoreDismiss = true } + var minimizing = false var dismissing = false if (bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0)) && !ignoreDismiss { - if self.interactivelyDismissed?() == true { + if self.interactivelyDismissed?(velocity.y) == true { dismissing = true + } else { + minimizing = true } } else if self.isExpanded { if velocity.y > 300.0 || offset > topInset / 2.0 { @@ -363,7 +366,9 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate { let previousBounds = bounds bounds.origin.y = 0.0 self.bounds = bounds - self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) + if !minimizing { + self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) + } } case .cancelled: self.panGestureArguments = nil @@ -391,8 +396,8 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate { return true } - func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) { - guard isExpanded != self.isExpanded else { + func update(isExpanded: Bool, force: Bool = false, transition: ContainedViewLayoutTransition) { + guard isExpanded != self.isExpanded || force else { return } self.isExpanded = isExpanded @@ -437,7 +442,7 @@ final class AttachmentContainer: ASDisplayNode, ASGestureRecognizerDelegate { }) let modalProgress = isLandscape ? 0.0 : (1.0 - topInset / defaultTopInset) - self.updateModalProgress?(modalProgress, topInset, transition) + self.updateModalProgress?(modalProgress, topInset, self.bounds, transition) let containerLayout: ContainerViewLayout let containerFrame: CGRect diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index b8ef8db046..32ad15d7cf 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -15,6 +15,7 @@ import LegacyMessageInputPanel import LegacyMessageInputPanelInputView import AttachmentTextInputPanelNode import ChatSendMessageActionUI +import MinimizedContainer public enum AttachmentButtonType: Equatable { case gallery @@ -342,7 +343,7 @@ public class AttachmentController: ViewController { } } - self.container.updateModalProgress = { [weak self] progress, topInset, transition in + self.container.updateModalProgress = { [weak self] progress, topInset, bounds, transition in if let strongSelf = self, let layout = strongSelf.validLayout, !strongSelf.isDismissing { var transition = transition if strongSelf.container.supernode == nil { @@ -350,7 +351,8 @@ public class AttachmentController: ViewController { } strongSelf.modalProgress = progress - strongSelf.controller?.modalTopEdgeOffset = topInset + strongSelf.controller?.minimizedTopEdgeOffset = topInset + strongSelf.controller?.minimizedBounds = bounds if !strongSelf.isMinimizing { strongSelf.controller?.updateModalStyleOverlayTransitionFactor(progress, transition: transition) @@ -364,16 +366,37 @@ public class AttachmentController: ViewController { } } - self.container.interactivelyDismissed = { [weak self] in - if let strongSelf = self { + self.container.interactivelyDismissed = { [weak self] velocity in + if let strongSelf = self, let layout = strongSelf.validLayout { if let controller = strongSelf.controller, controller.shouldMinimizeOnSwipe?() == true, let navigationController = controller.navigationController as? NavigationController { - navigationController.minimizeViewController(controller, animated: true) + + let delta = layout.size.height - controller.minimizedTopEdgeOffset + let damping: CGFloat = 180 + let initialVelocity: CGFloat = delta > 0.0 ? velocity / delta : 0.0 + + navigationController.minimizeViewController(controller, damping: damping, velocity: initialVelocity, setupContainer: { [weak self] current in + let minimizedContainer: MinimizedContainerImpl? + if let current = current as? MinimizedContainerImpl { + minimizedContainer = current + } else if let context = self?.controller?.context { + minimizedContainer = MinimizedContainerImpl(context: context, navigationController: navigationController) + } else { + minimizedContainer = nil + } + return minimizedContainer + }, animated: true) + + strongSelf.dim.isHidden = true + + strongSelf.isMinimizing = true + strongSelf.container.update(isExpanded: true, force: true, transition: .immediate) +// strongSelf.container.update(isExpanded: true, force: true, transition: .animated(duration: 0.4, curve: .customSpring(damping: 180.0, initialVelocity: initialVelocity))) + strongSelf.isMinimizing = false + + Queue.mainQueue().after(0.45, { + strongSelf.dim.isHidden = false + }) - Queue.mainQueue().after(0.5) { - strongSelf.isMinimizing = true - strongSelf.container.update(isExpanded: true, transition: .immediate) - strongSelf.isMinimizing = false - } return false } else { strongSelf.controller?.dismiss(animated: true) @@ -1038,13 +1061,31 @@ public class AttachmentController: ViewController { return self.buttons.contains(.standalone) } + private var snapshotView: UIView? public override var isMinimized: Bool { didSet { guard self.isMinimized != oldValue else { return } + if self.isMinimized { + if self.snapshotView == nil, let lastController = self.node.container.container.controllers.last, let snapshotView = lastController.view.snapshotView(afterScreenUpdates: false) { + snapshotView.isUserInteractionEnabled = false + self.snapshotView = snapshotView + lastController.view.addSubview(snapshotView) + } + } else { + if let snapshotView = self.snapshotView { + self.snapshotView = nil + Queue.mainQueue().after(0.5) { + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + snapshotView.removeFromSuperview() + }) + } + } + } + if !self.node.isDismissing { - let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) + let transition: ContainedViewLayoutTransition = self.isMinimized ? .immediate : .animated(duration: 0.2, curve: .easeInOut) transition.updateAlpha(node: self.node.dim, alpha: self.isMinimized ? 0.0 : 1.0) } } diff --git a/submodules/BrowserUI/Sources/BrowserStackContainerNode.swift b/submodules/BrowserUI/Sources/BrowserStackContainerNode.swift deleted file mode 100644 index 17b244e331..0000000000 --- a/submodules/BrowserUI/Sources/BrowserStackContainerNode.swift +++ /dev/null @@ -1,445 +0,0 @@ -import Foundation -import UIKit -import AsyncDisplayKit -import Display -import AppBundle - -let maxInteritemSpacing: CGFloat = 240.0 -let sectionInsetTop: CGFloat = 40.0 -let sectionInsetBottom: CGFloat = 0.0 -let zOffset: CGFloat = -60.0 - -let perspectiveCorrection: CGFloat = -1.0 / 1000.0 -let maxRotationAngle: CGFloat = -CGFloat.pi / 2.2 - -extension CATransform3D { - func interpolate(other: CATransform3D, progress: CGFloat) -> CATransform3D { - var vectors = Array(repeating: 0.0, count: 16) - vectors[0] = self.m11 + (other.m11 - self.m11) * progress - vectors[1] = self.m12 + (other.m12 - self.m12) * progress - vectors[2] = self.m13 + (other.m13 - self.m13) * progress - vectors[3] = self.m14 + (other.m14 - self.m14) * progress - vectors[4] = self.m21 + (other.m21 - self.m21) * progress - vectors[5] = self.m22 + (other.m22 - self.m22) * progress - vectors[6] = self.m23 + (other.m23 - self.m23) * progress - vectors[7] = self.m24 + (other.m24 - self.m24) * progress - vectors[8] = self.m31 + (other.m31 - self.m31) * progress - vectors[9] = self.m32 + (other.m32 - self.m32) * progress - vectors[10] = self.m33 + (other.m33 - self.m33) * progress - vectors[11] = self.m34 + (other.m34 - self.m34) * progress - vectors[12] = self.m41 + (other.m41 - self.m41) * progress - vectors[13] = self.m42 + (other.m42 - self.m42) * progress - vectors[14] = self.m43 + (other.m43 - self.m43) * progress - vectors[15] = self.m44 + (other.m44 - self.m44) * progress - - return CATransform3D(m11: vectors[0], m12: vectors[1], m13: vectors[2], m14: vectors[3], m21: vectors[4], m22: vectors[5], m23: vectors[6], m24: vectors[7], m31: vectors[8], m32: vectors[9], m33: vectors[10], m34: vectors[11], m41: vectors[12], m42: vectors[13], m43: vectors[14], m44: vectors[15]) - } -} - - -private func angle(for origin: CGFloat, itemCount: Int, bounds: CGRect, contentHeight: CGFloat?) -> CGFloat { - var rotationAngle = rotationAngleAt0(itemCount: itemCount) - - var contentOffset = bounds.origin.y - if contentOffset < 0.0 { - contentOffset *= 2.0 - } -// } else if let contentHeight = contentHeight, bounds.maxY > contentHeight { -//// let maxContentOffset = contentHeight - bounds.height -//// let delta = contentOffset - maxContentOffset -//// contentOffset = maxContentOffset + delta / 2.0 -// } - - var yOnScreen = origin - contentOffset - sectionInsetTop - if yOnScreen < 0 { - yOnScreen = 0 - } else if yOnScreen > bounds.height { - yOnScreen = bounds.height - } - - let maxRotationVariance = maxRotationAngle - rotationAngleAt0(itemCount: itemCount) - rotationAngle += (maxRotationVariance / bounds.height) * yOnScreen - - return rotationAngle -} - -private func final3dTransform(for origin: CGFloat, size: CGSize, contentHeight: CGFloat?, itemCount: Int, forcedAngle: CGFloat? = nil, additionalAngle: CGFloat? = nil, bounds: CGRect) -> CATransform3D { - var transform = CATransform3DIdentity - transform.m34 = perspectiveCorrection - - let rotationAngle = forcedAngle ?? angle(for: origin, itemCount: itemCount, bounds: bounds, contentHeight: contentHeight) - var effectiveRotationAngle = rotationAngle - if let additionalAngle = additionalAngle { - effectiveRotationAngle += additionalAngle - } - - let r = size.height / 2.0 + abs(zOffset / sin(rotationAngle)) - - let zTranslation = r * sin(rotationAngle) - let yTranslation: CGFloat = r * (1 - cos(rotationAngle)) - - let zTranslateTransform = CATransform3DTranslate(transform, 0.0, -yTranslation, zTranslation) - - let rotateTransform = CATransform3DRotate(zTranslateTransform, effectiveRotationAngle, 1.0, 0.0, 0.0) - - return rotateTransform -} - -private func interitemSpacing(itemCount: Int, bounds: CGRect) -> CGFloat { - var interitemSpacing = maxInteritemSpacing - if itemCount > 0 { - interitemSpacing = (bounds.height - sectionInsetTop - sectionInsetBottom) / CGFloat(min(itemCount, 5)) - } - return interitemSpacing -} - -private func frameForIndex(index: Int, size: CGSize, itemCount: Int, bounds: CGRect) -> CGRect { - let spacing = interitemSpacing(itemCount: itemCount, bounds: bounds) - let y = sectionInsetTop + spacing * CGFloat(index) - let origin = CGPoint(x: 0, y: y) - - return CGRect(origin: origin, size: size) -} - -private func rotationAngleAt0(itemCount: Int) -> CGFloat { - let multiplier: CGFloat = min(CGFloat(itemCount), 5.0) - 1.0 - return -CGFloat.pi / 7.0 - CGFloat.pi / 7.0 * multiplier / 4.0 -} - -private let shadowImage: UIImage? = { - return generateImage(CGSize(width: 1.0, height: 640.0), rotatedContext: { size, context in - let bounds = CGRect(origin: CGPoint(), size: size) - context.clear(bounds) - - let gradientColors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.55).cgColor, UIColor.black.withAlphaComponent(0.55).cgColor] as CFArray - - var locations: [CGFloat] = [0.0, 0.65, 1.0] - let colorSpace = CGColorSpaceCreateDeviceRGB() - let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! - context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: bounds.height), options: []) - }) -}() - -class StackItemContainerNode: ASDisplayNode { - private let node: ASDisplayNode - private let shadowNode: ASImageNode - - var tapped: (() -> Void)? - var highlighted: ((Bool) -> Void)? - - init(node: ASDisplayNode) { - self.node = node - self.shadowNode = ASImageNode() - self.shadowNode.displaysAsynchronously = false - self.shadowNode.displayWithoutProcessing = true - self.shadowNode.contentMode = .scaleToFill - - super.init() - - self.clipsToBounds = true - self.cornerRadius = 10.0 - applySmoothRoundedCorners(self.layer) - - self.shadowNode.image = shadowImage - - self.addSubnode(self.node) - self.addSubnode(self.shadowNode) - } - - override func didLoad() { - super.didLoad() - - let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) - recognizer.tapActionAtPoint = { point in - return .waitForSingleTap - } - recognizer.highlight = { [weak self] point in - if let point = point, point.x > 280.0 { - self?.highlighted?(true) - } else { - self?.highlighted?(false) - } - } - self.view.addGestureRecognizer(recognizer) - } - - func animateIn() { - self.shadowNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) - } - - @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { - switch recognizer.state { - case .ended: - if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation { - switch gesture { - case .tap: - self.tapped?() - default: - break - } - } - default: - break - } - } - - override func layout() { - super.layout() - - self.node.frame = self.bounds - self.shadowNode.frame = self.bounds - } -} - -public class StackContainerNode: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { - private let scrollNode: ASScrollNode - private var nodes: [StackItemContainerNode] - - private var deleteGestureRecognizer: UIPanGestureRecognizer? - private var offsetsForDeletingItems: [Int: CGPoint]? - private var currentDeletingIndexPath: Int? - private var deletingOffset: CGFloat? - - private var animatingIn = false - - private var validLayout: CGSize? - - override public init() { - self.scrollNode = ASScrollNode() - self.nodes = [] - - super.init() - - self.backgroundColor = .black - - self.addSubnode(self.scrollNode) - } - - override public func didLoad() { - super.didLoad() - - if #available(iOS 11.0, *) { - self.scrollNode.view.contentInsetAdjustmentBehavior = .never - } - - self.scrollNode.view.delegate = self.wrappedScrollViewDelegate - self.scrollNode.view.alwaysBounceVertical = true - - let deleteGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didPanToDelete(gestureRecognizer:))) - deleteGestureRecognizer.delegate = self.wrappedGestureRecognizerDelegate - deleteGestureRecognizer.delaysTouchesBegan = true - self.scrollNode.view.addGestureRecognizer(deleteGestureRecognizer) - self.deleteGestureRecognizer = deleteGestureRecognizer - } - - func item(forYPosition y: CGFloat) -> Int? { - let itemCount = self.nodes.count - let bounds = self.scrollNode.bounds - - let spacing = interitemSpacing(itemCount: itemCount, bounds: bounds) - return max(0, min(Int(floor((y - sectionInsetTop) / spacing)), itemCount - 1)) - } - - public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { - return false - } - - let touch = panGesture.location(in: gestureRecognizer.view) - let velocity = panGesture.velocity(in: gestureRecognizer.view) - - if abs(velocity.x) > abs(velocity.y), let item = self.item(forYPosition: touch.y) { - return item > 0 - } - return false - } - - @objc func didPanToDelete(gestureRecognizer: UIPanGestureRecognizer) { - let scrollView = self.scrollNode.view - - switch gestureRecognizer.state { - case .began: - let touch = gestureRecognizer.location(in: scrollView) - guard let item = self.item(forYPosition: touch.y) else { return } - - self.currentDeletingIndexPath = item - case .changed: - guard let _ = self.currentDeletingIndexPath else { return } - - var delta = gestureRecognizer.translation(in: scrollView) - delta.y = 0 - - if let offset = self.deletingOffset { - self.deletingOffset = offset + delta.x - } else { - self.deletingOffset = delta.x - } - - gestureRecognizer.setTranslation(.zero, in: scrollView) - - self.updateLayout() - case .ended: - if let _ = self.currentDeletingIndexPath { - if let offset = self.deletingOffset { - if offset < -self.frame.width / 2.0 { - self.deletingOffset = -self.frame.width - } else { - self.deletingOffset = nil - self.currentDeletingIndexPath = nil - } - } - } - - UIView.animate(withDuration: 0.3) { - self.updateLayout() - } - case .cancelled, .failed: - self.currentDeletingIndexPath = nil - self.deletingOffset = nil - default: - break - } - } - - func setup() { - let images: [UIImage] = [UIImage(bundleImageName: "Settings/test1")!, UIImage(bundleImageName: "Settings/test5")!, UIImage(bundleImageName: "Settings/test4")!, UIImage(bundleImageName: "Settings/test3")!, UIImage(bundleImageName: "Settings/test2")!] - for i in 0 ..< 5 { - let node = ASImageNode() - node.image = images[i] - - let containerNode = StackItemContainerNode(node: node) - containerNode.tapped = { [weak self] in - self?.animateIn(index: i) - } - containerNode.highlighted = { [weak self] highlighted in - self?.highlight(index: i, value: highlighted) - } - self.nodes.append(containerNode) - } - - var index: Int = 0 - let bounds = self.scrollNode.view.bounds - let itemCount = self.nodes.count - - for node in self.nodes { - self.scrollNode.addSubnode(node) - - let size = CGSize(width: self.frame.width, height: self.frame.height) - let frame = frameForIndex(index: index, size: size, itemCount: itemCount, bounds: bounds) - node.frame = frame - let transform = final3dTransform(for: frame.minY, size: frame.size, contentHeight: nil, itemCount: itemCount, bounds: bounds) - node.transform = transform - index += 1 - } - - if let lastFrame = self.nodes.last?.frame { - self.scrollNode.view.contentSize = CGSize(width: self.frame.width, height: lastFrame.minY) - } - } - - public func animateIn(index: Int) { - let node = self.nodes[index] - - self.animatingIn = true - self.scrollNode.view.isUserInteractionEnabled = false - node.animateIn() - UIView.animate(withDuration: 0.3) { - node.transform = CATransform3DIdentity - node.position = CGPoint(x: self.scrollNode.frame.width / 2.0, y: self.scrollNode.frame.height / 2.0) - } - - for i in 0 ..< index { - let node = self.nodes[i] - node.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -550.0), duration: 0.3, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring, mediaTimingFunction: nil, removeOnCompletion: false, additive: true, force: false, completion: nil) - } - - for i in (index + 1) ..< self.nodes.count { - let node = self.nodes[i] - node.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: 550.0), duration: 0.3, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring, mediaTimingFunction: nil, removeOnCompletion: false, additive: true, force: false, completion: nil) - } - } - - public func highlight(index: Int, value: Bool) { - let node = self.nodes[index] - - let bounds = self.scrollNode.view.bounds - let contentHeight = self.scrollNode.view.contentSize.height - let itemCount = self.nodes.count - - UIView.animate(withDuration: 0.4) { - let transform = final3dTransform(for: node.frame.minY, size: node.frame.size, contentHeight: contentHeight, itemCount: itemCount, additionalAngle: value ? 0.04 : nil, bounds: bounds) - node.transform = transform - } - } - - public func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard !self.animatingIn else { - return - } - self.updateLayout() - } - - func updateLayout() { - let bounds = self.scrollNode.view.bounds - let contentHeight = self.scrollNode.view.contentSize.height - let itemCount = self.nodes.count - - var index: Int = 0 - for node in self.nodes { - let initialTransform = final3dTransform(for: node.frame.minY, size: node.frame.size, contentHeight: contentHeight, itemCount: itemCount, bounds: bounds) - let initialFrame = frameForIndex(index: index, size: node.frame.size, itemCount: itemCount, bounds: bounds) - - var targetTransform: CATransform3D? - var targetPosition: CGPoint? - - var finalPosition = initialFrame.center - - if let deletingIndex = self.currentDeletingIndexPath, let offset = self.deletingOffset { - if deletingIndex == index { - finalPosition = CGPoint(x: self.frame.width / 2.0 + min(offset, 0.0), y: node.position.y) - } else if index < deletingIndex { - let frame = frameForIndex(index: index, size: node.frame.size, itemCount: itemCount - 1, bounds: bounds) - targetPosition = frame.center - - let spacing = interitemSpacing(itemCount: itemCount - 1, bounds: bounds) - targetTransform = final3dTransform(for: frame.minY, size: node.frame.size, contentHeight: contentHeight - node.frame.height - spacing, itemCount: itemCount - 1, bounds: bounds) - } else { - let frame = frameForIndex(index: index - 1, size: node.frame.size, itemCount: itemCount - 1, bounds: bounds) - targetPosition = frame.center - - let spacing = interitemSpacing(itemCount: itemCount - 1, bounds: bounds) - targetTransform = final3dTransform(for: frame.minY, size: node.frame.size, contentHeight: contentHeight - node.frame.height - spacing, itemCount: itemCount - 1, bounds: bounds) - } - } else { - node.position = initialFrame.center - } - - var finalTransform = initialTransform - if let targetTransform = targetTransform, let offset = self.deletingOffset { - let progress = min(1.0, abs(offset / (self.frame.width))) - finalTransform = initialTransform.interpolate(other: targetTransform, progress: progress) - } - - if let targetPosition = targetPosition, let offset = self.deletingOffset { - let progress = min(1.0, abs(offset / (self.frame.width))) - finalPosition = CGPoint(x: finalPosition.x + (targetPosition.x - finalPosition.x) * progress, y: finalPosition.y + (targetPosition.y - finalPosition.y) * progress) - } - - node.transform = finalTransform - node.position = finalPosition - - index += 1 - } - } - - public func update(size: CGSize) { - let hadValidLayout = self.validLayout != nil - self.validLayout = size - - self.scrollNode.frame = CGRect(origin: CGPoint(), size: size) - - if !hadValidLayout { - self.setup() - } - } -} diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index 5e0f529ccc..eea065a5f1 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -1177,9 +1177,11 @@ public extension ContainedViewLayoutTransition { self.updateTransform(layer: node.layer, transform: transform, beginWithCurrentState: beginWithCurrentState, delay: delay, completion: completion) } - func updateTransform(layer: CALayer, transform: CGAffineTransform, beginWithCurrentState: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { - let transform = CATransform3DMakeAffineTransform(transform) - + func updateTransform(node: ASDisplayNode, transform: CATransform3D, beginWithCurrentState: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { + self.updateTransform(layer: node.layer, transform: transform, beginWithCurrentState: beginWithCurrentState, delay: delay, completion: completion) + } + + func updateTransform(layer: CALayer, transform: CATransform3D, beginWithCurrentState: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { if CATransform3DEqualToTransform(layer.transform, transform) { if let completion = completion { completion(true) @@ -1206,6 +1208,11 @@ public extension ContainedViewLayoutTransition { }) } } + + func updateTransform(layer: CALayer, transform: CGAffineTransform, beginWithCurrentState: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { + let transform = CATransform3DMakeAffineTransform(transform) + self.updateTransform(layer: layer, transform: transform, beginWithCurrentState: beginWithCurrentState, delay: delay, completion: completion) + } func updateTransformScale(node: ASDisplayNode, scale: CGFloat, beginWithCurrentState: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { let t = node.layer.transform diff --git a/submodules/Display/Source/Navigation/MinimizedContainer.swift b/submodules/Display/Source/Navigation/MinimizedContainer.swift new file mode 100644 index 0000000000..a937bb8b12 --- /dev/null +++ b/submodules/Display/Source/Navigation/MinimizedContainer.swift @@ -0,0 +1,13 @@ +import Foundation +import AsyncDisplayKit + +public protocol MinimizedContainer: ASDisplayNode { + var willMaximize: (() -> Void)? { get set } + + func addController(_ viewController: ViewController, transition: ContainedViewLayoutTransition) + func maximizeController(_ viewController: ViewController, animated: Bool, completion: @escaping (Bool) -> Void) + func dismissAll(completion: @escaping () -> Void) + + func updateLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) + func collapsedHeight(layout: ContainerViewLayout) -> CGFloat +} diff --git a/submodules/Display/Source/Navigation/NavigationController.swift b/submodules/Display/Source/Navigation/NavigationController.swift index 9fcf1c5682..6e92565928 100644 --- a/submodules/Display/Source/Navigation/NavigationController.swift +++ b/submodules/Display/Source/Navigation/NavigationController.swift @@ -150,6 +150,7 @@ open class NavigationController: UINavigationController, ContainableController, private var rootModalFrame: NavigationModalFrame? private var modalContainers: [NavigationModalContainer] = [] private var overlayContainers: [NavigationOverlayContainer] = [] + private var minimizedContainer: MinimizedContainer? private var globalOverlayContainers: [NavigationOverlayContainer] = [] private var globalOverlayBelowKeyboardContainerParent: GlobalOverlayContainerParent? @@ -180,15 +181,6 @@ open class NavigationController: UINavigationController, ContainableController, } } - private var _minimizedViewControllers: [ViewController] = [] - open var minimizedViewControllers: [UIViewController] { - get { - return self._minimizedViewControllers.map { $0 as UIViewController } - } set(value) { - self.setMinimizedViewControllers(value, animated: false) - } - } - private var _viewControllersPromise = ValuePromise<[UIViewController]>() public var viewControllersSignal: Signal<[UIViewController], NoError> { return _viewControllersPromise.get() @@ -475,7 +467,7 @@ open class NavigationController: UINavigationController, ContainableController, transition.updateFrame(node: globalOverlayContainerParent, frame: CGRect(origin: CGPoint(), size: layout.size)) } - let navigationLayout = makeNavigationLayout(mode: self.mode, layout: layout, controllers: self._viewControllers, minimizedControllers: self._minimizedViewControllers) + let navigationLayout = makeNavigationLayout(mode: self.mode, layout: layout, controllers: self._viewControllers) var transition = transition var statusBarStyle: StatusBarStyle = .Ignore @@ -497,9 +489,8 @@ open class NavigationController: UINavigationController, ContainableController, let modalContainer: NavigationModalContainer if let existingModalContainer = existingModalContainer { modalContainer = existingModalContainer - modalContainer.isMinimized = navigationLayout.modal[i].isMinimized } else { - modalContainer = NavigationModalContainer(theme: self.theme, isFlat: navigationLayout.modal[i].isFlat, isMinimized: navigationLayout.modal[i].isMinimized, controllerRemoved: { [weak self] controller in + modalContainer = NavigationModalContainer(theme: self.theme, isFlat: navigationLayout.modal[i].isFlat, controllerRemoved: { [weak self] controller in self?.controllerRemoved(controller) }) modalContainer.container.statusBarStyleUpdated = { [weak self] transition in @@ -534,32 +525,6 @@ open class NavigationController: UINavigationController, ContainableController, strongSelf.setViewControllers(controllers, animated: false) strongSelf.ignoreInputHeight = false } - modalContainer.minimizedRequestMaximize = { [weak self] in - guard let self else { - return - } - - var controllers = self._viewControllers - for controller in self._minimizedViewControllers { - controllers.append(controller) - } - self._viewControllers = controllers - self._minimizedViewControllers = [] - - self.updateContainersNonReentrant(transition: .animated(duration: 0.5, curve: .spring)) - } - modalContainer.minimizedRequestDismiss = { [weak self, weak modalContainer] animated in - guard let self, let modalContainer else { - return - } - - let minimizedControllers = self.minimizedViewControllers.filter { controller in - return !modalContainer.container.controllers.contains(where: { $0 === controller }) - } - if minimizedControllers.count != self.minimizedViewControllers.count { - self.setMinimizedViewControllers(minimizedControllers, animated: animated) - } - } } modalContainers.append(modalContainer) } @@ -731,7 +696,6 @@ open class NavigationController: UINavigationController, ContainableController, var topVisibleModalContainerWithStatusBar: NavigationModalContainer? var visibleModalCount = 0 var topModalIsFlat = false - var topModalIsMinimized = false var topFlatModalHasProgress = false let isLandscape = layout.orientation == .landscape var hasVisibleStandaloneModal = false @@ -771,7 +735,7 @@ open class NavigationController: UINavigationController, ContainableController, modalStyleOverlayTransitionFactor = max(modalStyleOverlayTransitionFactor, lastController.modalStyleOverlayTransitionFactor) topFlatModalHasProgress = modalStyleOverlayTransitionFactor > 0.0 } - + containerTransition.updateFrame(node: modalContainer, frame: CGRect(origin: CGPoint(), size: layout.size)) modalContainer.update(layout: modalContainer.isFlat ? globalOverlayLayout : layout, controllers: navigationLayout.modal[i].controllers, coveredByModalTransition: effectiveModalTransition, transition: containerTransition) @@ -791,16 +755,13 @@ open class NavigationController: UINavigationController, ContainableController, } if modalContainer.supernode != nil { - if !hasVisibleStandaloneModal && !isStandaloneModal && !modalContainer.isFlat && !modalContainer.isMinimized { + if !hasVisibleStandaloneModal && !isStandaloneModal && !modalContainer.isFlat { visibleModalCount += 1 } if isStandaloneModal { hasVisibleStandaloneModal = true visibleModalCount = 0 } - - topModalIsMinimized = modalContainer.isMinimized - if previousModalContainer == nil { topModalIsFlat = modalContainer.isFlat @@ -857,6 +818,11 @@ open class NavigationController: UINavigationController, ContainableController, } } + if self.isMaximizing && layout.size.width < layout.size.height { + modalStyleOverlayTransitionFactor = 1.0 + topFlatModalHasProgress = true + } + layout.additionalInsets.left = max(layout.intrinsicInsets.left, additionalSideInsets.left) layout.additionalInsets.right = max(layout.intrinsicInsets.right, additionalSideInsets.right) @@ -865,18 +831,18 @@ open class NavigationController: UINavigationController, ContainableController, if let rootContainer = self.rootContainer { switch rootContainer { case let .flat(flatContainer): - if let previousModalContainer, !previousModalContainer.isMinimized { - flatContainer.keyboardViewManager = nil - flatContainer.canHaveKeyboardFocus = false - } else { + if previousModalContainer == nil { flatContainer.keyboardViewManager = self.keyboardViewManager flatContainer.canHaveKeyboardFocus = true + } else { + flatContainer.keyboardViewManager = nil + flatContainer.canHaveKeyboardFocus = false } - + var updatedSize = layout.size var updatedIntrinsicInsets = layout.intrinsicInsets - if topModalIsMinimized && (layout.inputHeight ?? 0.0).isZero { - updatedSize.height -= 81.0 + if let minimizedContainer = self.minimizedContainer, (layout.inputHeight ?? 0.0).isZero { + updatedSize.height -= minimizedContainer.collapsedHeight(layout: layout) updatedIntrinsicInsets.bottom = 0.0 } let updatedLayout = layout.withUpdatedSize(updatedSize).withUpdatedIntrinsicInsets(updatedIntrinsicInsets) @@ -1145,6 +1111,11 @@ open class NavigationController: UINavigationController, ContainableController, } } + if let minimizedContainer = self.minimizedContainer { + minimizedContainer.frame = CGRect(origin: .zero, size: layout.size) + minimizedContainer.updateLayout(layout, transition: transition) + } + if self.inCallStatusBar != nil { statusBarStyle = .White } @@ -1432,11 +1403,6 @@ open class NavigationController: UINavigationController, ContainableController, self.setViewControllers(controllers, animated: animated) self.ignoreInputHeight = false } - - let minimizedControllers = self.minimizedViewControllers.filter({ $0 !== controller }) - if minimizedControllers.count != self.minimizedViewControllers.count { - self.setMinimizedViewControllers(minimizedControllers, animated: animated) - } } public func replaceController(_ controller: ViewController, with other: ViewController, animated: Bool) { @@ -1575,28 +1541,71 @@ open class NavigationController: UINavigationController, ContainableController, } self._viewControllersPromise.set(self.viewControllers) } - - private func setMinimizedViewControllers(_ viewControllers: [UIViewController], animated: Bool) { - self._viewControllers = self._viewControllers.filter { controller in - return !viewControllers.contains(controller) - } - self._minimizedViewControllers = viewControllers.map { controller in - let controller = controller as! ViewController - controller.navigation_setNavigationController(self) - return controller - } - if let layout = self.validLayout { - self.updateContainers(layout: layout, transition: animated ? .animated(duration: 0.5, curve: .spring) : .immediate, completion: { [weak self] in - self?.notifyAccessibilityScreenChanged() - }) + public func minimizeViewController(_ viewController: ViewController, damping: CGFloat?, velocity: CGFloat? = nil, setupContainer: (MinimizedContainer?) -> MinimizedContainer?, animated: Bool) { + let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .customSpring(damping: damping ?? 124.0, initialVelocity: velocity ?? 0.0)) : .immediate + + let minimizedContainer = setupContainer(self.minimizedContainer) + if self.minimizedContainer !== minimizedContainer { + minimizedContainer?.willMaximize = { [weak self] in + guard let self else { + return + } + self.isMaximizing = true + self.updateContainersNonReentrant(transition: .animated(duration: 0.4, curve: .spring)) + } + self.minimizedContainer?.removeFromSupernode() + self.minimizedContainer = minimizedContainer + + if let minimizedContainer { + if let modalContainer = self.modalContainers.first { + self.displayNode.insertSubnode(minimizedContainer, belowSubnode: modalContainer) + } else { + self.displayNode.addSubnode(minimizedContainer) + } + } + + self.updateContainersNonReentrant(transition: transition) } + self.filterController(viewController, animated: true) + minimizedContainer?.addController(viewController, transition: transition) } - public func minimizeViewController(_ viewController: UIViewController, animated: Bool) { - var controllers = self.minimizedViewControllers - controllers.append(viewController) - self.setMinimizedViewControllers(controllers, animated: animated) + private var isMaximizing = false + public func maximizeViewController(_ viewController: ViewController, animated: Bool) { + guard let minimizedContainer = self.minimizedContainer else { + return + } + if animated { + self.isMaximizing = true + self.updateContainersNonReentrant(transition: .animated(duration: 0.4, curve: .spring)) + } + minimizedContainer.maximizeController(viewController, animated: animated, completion: { [weak self] dismissed in + guard let self else { + return + } + var viewControllers = self.viewControllers + viewControllers.append(viewController) + self.setViewControllers(viewControllers, animated: false) + self.isMaximizing = false + + if dismissed, let minimizedContainer = self.minimizedContainer { + self.minimizedContainer = nil + minimizedContainer.removeFromSupernode() + } + }) + } + + public func dismissMinimizedControllers(animated: Bool) { + guard let minimizedContainer = self.minimizedContainer else { + return + } + self.minimizedContainer = nil + + minimizedContainer.dismissAll(completion: { [weak minimizedContainer] in + minimizedContainer?.removeFromSupernode() + }) + self.updateContainersNonReentrant(transition: animated ? .animated(duration: 0.4, curve: .spring) : .immediate) } public var _keepModalDismissProgress = false @@ -1862,8 +1871,9 @@ open class NavigationController: UINavigationController, ContainableController, return } transition.updateTransform(node: container, transform: CGAffineTransformMakeTranslation(offset, 0.0)) - if let minimizedModalContainer = self.modalContainers.first(where: { $0.isMinimized }) { - transition.updateTransform(node: minimizedModalContainer, transform: CGAffineTransformMakeTranslation(offset, 0.0)) + + if let minimizedContainer = self.minimizedContainer { + transition.updateTransform(node: minimizedContainer, transform: CGAffineTransformMakeTranslation(offset, 0.0)) } } } diff --git a/submodules/Display/Source/Navigation/NavigationLayout.swift b/submodules/Display/Source/Navigation/NavigationLayout.swift index 9e6d68f47d..839c4f41ea 100644 --- a/submodules/Display/Source/Navigation/NavigationLayout.swift +++ b/submodules/Display/Source/Navigation/NavigationLayout.swift @@ -12,7 +12,6 @@ struct ModalContainerLayout { var controllers: [ViewController] var isFlat: Bool var isStandalone: Bool - var isMinimized: Bool } struct NavigationLayout { @@ -20,7 +19,7 @@ struct NavigationLayout { var modal: [ModalContainerLayout] } -func makeNavigationLayout(mode: NavigationControllerMode, layout: ContainerViewLayout, controllers: [ViewController], minimizedControllers: [ViewController]) -> NavigationLayout { +func makeNavigationLayout(mode: NavigationControllerMode, layout: ContainerViewLayout, controllers: [ViewController]) -> NavigationLayout { var rootControllers: [ViewController] = [] var modalStack: [ModalContainerLayout] = [] for controller in controllers { @@ -55,7 +54,7 @@ func makeNavigationLayout(mode: NavigationControllerMode, layout: ContainerViewL if requiresModal { controller._presentedInModal = true if beginsModal || modalStack.isEmpty || modalStack[modalStack.count - 1].isStandalone { - modalStack.append(ModalContainerLayout(controllers: [controller], isFlat: isFlat, isStandalone: isStandalone, isMinimized: false)) + modalStack.append(ModalContainerLayout(controllers: [controller], isFlat: isFlat, isStandalone: isStandalone)) } else { modalStack[modalStack.count - 1].controllers.append(controller) } @@ -65,7 +64,7 @@ func makeNavigationLayout(mode: NavigationControllerMode, layout: ContainerViewL controller._presentedInModal = true } if modalStack[modalStack.count - 1].isStandalone { - modalStack.append(ModalContainerLayout(controllers: [controller], isFlat: isFlat, isStandalone: isStandalone, isMinimized: false)) + modalStack.append(ModalContainerLayout(controllers: [controller], isFlat: isFlat, isStandalone: isStandalone)) } else { modalStack[modalStack.count - 1].controllers.append(controller) } @@ -75,22 +74,6 @@ func makeNavigationLayout(mode: NavigationControllerMode, layout: ContainerViewL } } - var minimizedModalContainer: ModalContainerLayout? - for controller in minimizedControllers { - controller._presentedInModal = false - if var container = minimizedModalContainer { - container.controllers.append(controller) - minimizedModalContainer = container - } else { - let container = ModalContainerLayout(controllers: [controller], isFlat: false, isStandalone: false, isMinimized: true) - minimizedModalContainer = container - } - } - - if let minimizedModalContainer { - modalStack.insert(minimizedModalContainer, at: 0) - } - let rootLayout: RootNavigationLayout switch mode { case .single: diff --git a/submodules/Display/Source/Navigation/NavigationModalContainer.swift b/submodules/Display/Source/Navigation/NavigationModalContainer.swift index 2d5325d8a0..1e7603dcbf 100644 --- a/submodules/Display/Source/Navigation/NavigationModalContainer.swift +++ b/submodules/Display/Source/Navigation/NavigationModalContainer.swift @@ -4,36 +4,14 @@ import AsyncDisplayKit import SwiftSignalKit import UIKitRuntimeUtils -private let minimizedMask: UIImage? = { - return generateImage(CGSize(width: 22.0, height: 24.0), rotatedContext: { size, context in - context.setFillColor(UIColor.black.cgColor) - context.fill(CGRect(origin: .zero, size: size)) - - context.setBlendMode(.clear) - context.setFillColor(UIColor.clear.cgColor) - - let path = UIBezierPath(roundedRect: CGRect(x: 0, y: -10, width: 22, height: 20), cornerRadius: 10) - context.addPath(path.cgPath) - context.fillPath() - })?.stretchableImage(withLeftCapWidth: 11, topCapHeight: 12) -}() - final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { private var theme: NavigationControllerTheme let isFlat: Bool - var isMinimized: Bool - var appliedIsMinimized: Bool = false - private let minimizedFrameNode: ASImageNode private let dim: ASDisplayNode private let scrollNode: ASScrollNode let container: NavigationContainer - private let minimizedBackgroundNode: ASDisplayNode - private let minimizedTitleNode: ImmediateTextNode - private let minimizedCloseButton: HighlightableButtonNode - private var minimizedTitleDisposable: Disposable? - private var panRecognizer: InteractiveTransitionGestureRecognizer? private(set) var isReady: Bool = false @@ -42,9 +20,6 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes var updateDismissProgress: ((CGFloat, ContainedViewLayoutTransition) -> Void)? var interactivelyDismissed: ((Bool) -> Void)? - var minimizedRequestDismiss: ((Bool) -> Void)? - var minimizedRequestMaximize: (() -> Void)? - private var isUpdatingState = false private var ignoreScrolling = false private var isDismissed = false @@ -67,14 +42,9 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes } } - init(theme: NavigationControllerTheme, isFlat: Bool, isMinimized: Bool, controllerRemoved: @escaping (ViewController) -> Void) { + init(theme: NavigationControllerTheme, isFlat: Bool, controllerRemoved: @escaping (ViewController) -> Void) { self.theme = theme self.isFlat = isFlat - self.isMinimized = isMinimized - - self.minimizedFrameNode = ASImageNode() - self.minimizedFrameNode.contentMode = .scaleToFill - self.minimizedFrameNode.image = minimizedMask self.dim = ASDisplayNode() self.dim.alpha = 0.0 @@ -84,28 +54,12 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes self.container = NavigationContainer(isFlat: false, controllerRemoved: controllerRemoved) self.container.clipsToBounds = true - self.minimizedBackgroundNode = ASDisplayNode() - self.minimizedBackgroundNode.clipsToBounds = true - self.minimizedBackgroundNode.backgroundColor = theme.navigationBar.opaqueBackgroundColor - - self.minimizedTitleNode = ImmediateTextNode() - - self.minimizedCloseButton = HighlightableButtonNode() - self.minimizedCloseButton.setImage(UIImage(bundleImageName: "Instant View/Close"), for: .normal) - super.init() - self.addSubnode(self.minimizedFrameNode) self.addSubnode(self.dim) self.addSubnode(self.scrollNode) self.scrollNode.addSubnode(self.container) - self.addSubnode(self.minimizedBackgroundNode) - self.minimizedBackgroundNode.addSubnode(self.minimizedTitleNode) - self.minimizedBackgroundNode.addSubnode(self.minimizedCloseButton) - - self.minimizedCloseButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: .touchUpInside) - self.isReady = self.container.isReady self.container.isReadyUpdated = { [weak self] in guard let strongSelf = self else { @@ -120,11 +74,6 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes } applySmoothRoundedCorners(self.container.layer) - applySmoothRoundedCorners(self.minimizedBackgroundNode.layer) - } - - deinit { - self.minimizedTitleDisposable?.dispose() } override func didLoad() { @@ -167,34 +116,26 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes self.view.addGestureRecognizer(panRecognizer) self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) } - - self.minimizedBackgroundNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.maximizeTapGesture(_:)))) } public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer.view === self.minimizedBackgroundNode.view { - return self.isMinimized - } else if !self.isMinimized { - if gestureRecognizer == self.panRecognizer, let gestureRecognizer = self.panRecognizer, gestureRecognizer.numberOfTouches == 0 { - let translation = gestureRecognizer.velocity(in: gestureRecognizer.view) - if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 { - return false - } - if translation.x < 4.0 { - return false - } - if self.isDismissed { - return false - } - return true - } else { - return true + if gestureRecognizer == self.panRecognizer, let gestureRecognizer = self.panRecognizer, gestureRecognizer.numberOfTouches == 0 { + let translation = gestureRecognizer.velocity(in: gestureRecognizer.view) + if abs(translation.y) > 4.0 && abs(translation.y) > abs(translation.x) * 2.5 { + return false } + if translation.x < 4.0 { + return false + } + if self.isDismissed { + return false + } + return true } else { return true } } - + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return false } @@ -384,8 +325,6 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes self.validLayout = layout - let lastControllerUpdated = self.container.controllers.last !== controllers.last - var isStandaloneModal = false if let controller = controllers.first, case .standaloneModal = controller.navigationPresentation { isStandaloneModal = true @@ -418,7 +357,7 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes if layout.metrics.widthClass == .compact || self.isFlat { self.panRecognizer?.isEnabled = true self.container.clipsToBounds = true - if self.isFlat || self.isMinimized { + if self.isFlat { self.dim.backgroundColor = .clear } else { self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25) @@ -427,7 +366,6 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes self.container.cornerRadius = 0.0 } else { self.container.cornerRadius = 10.0 - self.minimizedBackgroundNode.cornerRadius = self.container.cornerRadius } if #available(iOS 11.0, *) { @@ -436,8 +374,6 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes } else { self.container.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] } - - self.minimizedBackgroundNode.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] } var topInset: CGFloat @@ -450,18 +386,10 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes containerFrame = unscaledFrame } else { topInset = 10.0 - - let height: CGFloat - if self.isMinimized { - height = layout.size.height - topInset - topInset = layout.size.height - 78.0 - } else { - if self.isFlat { - topInset = 0.0 - } else if let statusBarHeight = layout.statusBarHeight { - topInset += statusBarHeight - } - height = layout.size.height - topInset + if self.isFlat { + topInset = 0.0 + } else if let statusBarHeight = layout.statusBarHeight { + topInset += statusBarHeight } let effectiveStatusBarHeight: CGFloat? @@ -471,7 +399,7 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes effectiveStatusBarHeight = nil } - containerLayout = ContainerViewLayout(size: CGSize(width: layout.size.width, height: height), metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: layout.intrinsicInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.intrinsicInsets.right), safeInsets: UIEdgeInsets(top: 0.0, left: layout.safeInsets.left, bottom: layout.safeInsets.bottom, right: layout.safeInsets.right), additionalInsets: layout.additionalInsets, statusBarHeight: effectiveStatusBarHeight, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver) + containerLayout = ContainerViewLayout(size: CGSize(width: layout.size.width, height: layout.size.height - topInset), metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: 0.0, left: layout.intrinsicInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.intrinsicInsets.right), safeInsets: UIEdgeInsets(top: 0.0, left: layout.safeInsets.left, bottom: layout.safeInsets.bottom, right: layout.safeInsets.right), additionalInsets: layout.additionalInsets, statusBarHeight: effectiveStatusBarHeight, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver) let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: topInset - coveredByModalTransition * 10.0), size: containerLayout.size) let maxScale: CGFloat = (containerLayout.size.width - 16.0 * 2.0) / containerLayout.size.width containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition @@ -479,59 +407,6 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes let scaledTopInset: CGFloat = topInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) } - - for controller in controllers { - controller.isMinimized = self.isMinimized - } - - if self.isMinimized != self.appliedIsMinimized { - self.appliedIsMinimized = self.isMinimized - - if self.isMinimized { - let modalTopEdgeOffset = (controllers.last?.modalTopEdgeOffset ?? 0.0) + 96.0 - if transition.isAnimated { - self.minimizedBackgroundNode.position = self.minimizedBackgroundNode.position.offsetBy(dx: 0.0, dy: modalTopEdgeOffset) - } - } - } - - transition.updateFrame(node: self.minimizedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: containerFrame.minY), size: CGSize(width: layout.size.width, height: 243.0))) - transition.updateAlpha(node: self.minimizedBackgroundNode, alpha: self.isMinimized ? 1.0 : 0.0) - self.minimizedBackgroundNode.cornerRadius = 10.0 - self.minimizedBackgroundNode.isUserInteractionEnabled = self.isMinimized - - let titleSideInset: CGFloat = 56.0 - if self.isMinimized, let controller = controllers.last { - if lastControllerUpdated || self.minimizedTitleDisposable == nil { - var isFirstUpdate = true - self.minimizedTitleDisposable = (controller.titleSignal - |> deliverOnMainQueue).start(next: { [weak self] title in - guard let self, let layout = self.validLayout else { - return - } - - self.minimizedTitleNode.attributedText = NSAttributedString(string: title ?? "", font: Font.bold(17.0), textColor: self.theme.navigationBar.primaryTextColor) - - if !isFirstUpdate { - let titleSize = self.minimizedTitleNode.updateLayout(CGSize(width: layout.size.width - titleSideInset * 2.0, height: 56.0)) - self.minimizedTitleNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - titleSize.width) / 2.0), y: 18.0), size: titleSize) - } else { - isFirstUpdate = false - } - }) - } - } else { - self.minimizedTitleDisposable?.dispose() - self.minimizedTitleDisposable = nil - } - - let titleSize = self.minimizedTitleNode.updateLayout(CGSize(width: layout.size.width - titleSideInset * 2.0, height: 56.0)) - transition.updateFrame(node: self.minimizedTitleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - titleSize.width) / 2.0), y: 18.0), size: titleSize)) - - transition.updateFrame(node: self.minimizedCloseButton, frame: CGRect(origin: CGPoint(x: 0.0, y: 3.0), size: CGSize(width: 46.0, height: 52.0))) - - transition.updateAlpha(node: self.minimizedFrameNode, alpha: self.isMinimized ? 1.0 : 0.0) - transition.updateFrame(node: self.minimizedFrameNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - 81.0 - 10.0), size: CGSize(width: layout.size.width, height: 24.0 + 81.0))) } else { self.panRecognizer?.isEnabled = false if self.isFlat { @@ -610,17 +485,7 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) alphaTransition.updateAlpha(node: self.dim, alpha: 0.0, beginWithCurrentState: true) - - let targetY: CGFloat - if self.isMinimized { - let offset: CGFloat = 81.0 + 15.0 - targetY = self.container.position.y + offset - positionTransition.updatePosition(node: self.minimizedBackgroundNode, position: CGPoint(x: self.minimizedBackgroundNode.position.x, y: self.minimizedBackgroundNode.position.y + offset)) - } else { - targetY = self.bounds.height + self.container.bounds.height / 2.0 + self.bounds.height - } - - positionTransition.updatePosition(node: self.container, position: CGPoint(x: self.container.position.x, y: targetY), beginWithCurrentState: true, completion: { [weak self] _ in + positionTransition.updatePosition(node: self.container, position: CGPoint(x: self.container.position.x, y: self.bounds.height + self.container.bounds.height / 2.0 + self.bounds.height), beginWithCurrentState: true, completion: { [weak self] _ in guard let strongSelf = self else { return } @@ -648,14 +513,7 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes return nil } if !self.container.bounds.contains(self.view.convert(point, to: self.container.view)) { - if self.isMinimized { - return nil - } else { - return self.dim.view - } - } - if self.isMinimized && result == self.minimizedBackgroundNode.view { - return result + return self.dim.view } if self.isFlat { return result @@ -710,22 +568,4 @@ final class NavigationModalContainer: ASDisplayNode, ASScrollViewDelegate, ASGes self.scrollNode.view.isScrollEnabled = enableScrolling && !self.isFlat return result } - - @objc private func closePressed() { - if !self.isDismissed { - self.minimizedRequestDismiss?(true) - } - } - - @objc private func maximizeTapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - if !self.isDismissed { - if self.container.controllers.count == 1 { - self.minimizedRequestMaximize?() - } else { - - } - } - } - } } diff --git a/submodules/Display/Source/ViewController.swift b/submodules/Display/Source/ViewController.swift index c34ef5e3b5..6e97f25dca 100644 --- a/submodules/Display/Source/ViewController.swift +++ b/submodules/Display/Source/ViewController.swift @@ -230,7 +230,8 @@ public protocol CustomViewControllerNavigationDataSummary: AnyObject { private var navigationBarOrigin: CGFloat = 0.0 - public var modalTopEdgeOffset: CGFloat = 0.0 + public var minimizedTopEdgeOffset: CGFloat = 0.0 + public var minimizedBounds: CGRect? open var isMinimized: Bool = false open var interactiveNavivationGestureEdgeWidth: InteractiveTransitionGestureRecognizerEdgeWidth? { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/Sources/ChatMessageStarsMediaInfoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/Sources/ChatMessageStarsMediaInfoNode.swift index f64d02df9a..173a1a3f13 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/Sources/ChatMessageStarsMediaInfoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStarsMediaInfoNode/Sources/ChatMessageStarsMediaInfoNode.swift @@ -235,9 +235,10 @@ public class ChatMessageStarsMediaInfoNode: ASDisplayNode { let text: NSMutableAttributedString if let peer = arguments.message.peers[arguments.message.id.peerId] as? TelegramChannel, peer.flags.contains(.isCreator) || peer.adminRights != nil { - text = NSMutableAttributedString(string: "⭐️\(arguments.media.amount)", font: textFont, textColor: .white) + let amountString = presentationStringsFormattedNumber(Int32(arguments.media.amount), arguments.presentationData.dateTimeFormat.groupingSeparator) + text = NSMutableAttributedString(string: "⭐️\(amountString)", font: textFont, textColor: .white) } else { - text = NSMutableAttributedString(string: "Purchased", font: textFont, textColor: .white) + text = NSMutableAttributedString(string: arguments.presentationData.strings.Chat_PaidMedia_Purchased, font: textFont, textColor: .white) } var offset: CGFloat = 0.0 diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/Sources/ChatMessageUnlockMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/Sources/ChatMessageUnlockMediaNode.swift index f0481caa95..91b51a70a9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/Sources/ChatMessageUnlockMediaNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageUnlockMediaNode/Sources/ChatMessageUnlockMediaNode.swift @@ -130,7 +130,8 @@ public class ChatMessageUnlockMediaNode: ASDisplayNode { let textFont = Font.medium(fontSize) let padding: CGFloat = 10.0 - let text = NSMutableAttributedString(string: arguments.presentationData.strings.Chat_UnlockMedia("⭐️ \(arguments.media.amount)").string, font: textFont, textColor: .white) + let amountString = presentationStringsFormattedNumber(Int32(arguments.media.amount), arguments.presentationData.dateTimeFormat.groupingSeparator) + let text = NSMutableAttributedString(string: arguments.presentationData.strings.Chat_PaidMedia_UnlockMedia("⭐️ \(amountString)").string, font: textFont, textColor: .white) if let range = text.string.range(of: "⭐️") { text.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: NSRange(range, in: text.string)) text.addAttribute(.baselineOffset, value: 0.5, range: NSRange(range, in: text.string)) diff --git a/submodules/TelegramUI/Components/MinimizedContainer/BUILD b/submodules/TelegramUI/Components/MinimizedContainer/BUILD new file mode 100644 index 0000000000..7585ab96ad --- /dev/null +++ b/submodules/TelegramUI/Components/MinimizedContainer/BUILD @@ -0,0 +1,23 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MinimizedContainer", + module_name = "MinimizedContainer", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramPresentationData", + "//submodules/ComponentFlow", + "//submodules/AccountContext", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/MinimizedContainer/Sources/HeaderNode.swift b/submodules/TelegramUI/Components/MinimizedContainer/Sources/HeaderNode.swift new file mode 100644 index 0000000000..f8e5c204ae --- /dev/null +++ b/submodules/TelegramUI/Components/MinimizedContainer/Sources/HeaderNode.swift @@ -0,0 +1,142 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import SwiftSignalKit +import TelegramPresentationData + +final class MinimizedHeaderNode: ASDisplayNode { + var theme: NavigationControllerTheme + let strings: PresentationStrings + + private let minimizedBackgroundNode: ASDisplayNode + private let minimizedTitleNode: ImmediateTextNode + private let minimizedCloseButton: HighlightableButtonNode + private var minimizedTitleDisposable: Disposable? + + private var _controllers: [Weak] = [] + var controllers: [ViewController] { + get { + return self._controllers.compactMap { $0.value } + } + set { + if !newValue.isEmpty { + if newValue.count != self.controllers.count { + self._controllers = newValue.map { Weak($0) } + + self.minimizedTitleDisposable?.dispose() + self.minimizedTitleDisposable = nil + + var signals: [Signal] = [] + for controller in newValue { + signals.append(controller.titleSignal) + } + + self.minimizedTitleDisposable = (combineLatest(signals) + |> deliverOnMainQueue).start(next: { [weak self] titles in + guard let self else { + return + } + let titles = titles.compactMap { $0 } + if titles.count == 1, let title = titles.first { + self.title = title + } else if let title = titles.last { + let othersString = self.strings.WebApp_MinimizedTitle_Others(Int32(titles.count - 1)) + self.title = self.strings.WebApp_MinimizedTitleFormat(title, othersString).string + } else { + self.title = nil + } + }) + } + } else { + self.minimizedTitleDisposable?.dispose() + self.minimizedTitleDisposable = nil + } + } + } + + var title: String? { + didSet { + if let (size, insets) = self.validLayout { + self.update(size: size, insets: insets, transition: .immediate) + } + } + } + + var requestClose: () -> Void = {} + var requestMaximize: () -> Void = {} + + private var validLayout: (CGSize, UIEdgeInsets)? + + init(theme: NavigationControllerTheme, strings: PresentationStrings) { + self.theme = theme + self.strings = strings + + self.minimizedBackgroundNode = ASDisplayNode() + self.minimizedBackgroundNode.cornerRadius = 10.0 + self.minimizedBackgroundNode.clipsToBounds = true + self.minimizedBackgroundNode.backgroundColor = theme.navigationBar.opaqueBackgroundColor + if #available(iOS 11.0, *) { + self.minimizedBackgroundNode.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + } + + self.minimizedTitleNode = ImmediateTextNode() + + self.minimizedCloseButton = HighlightableButtonNode() + self.minimizedCloseButton.setImage(UIImage(bundleImageName: "Instant View/Close"), for: .normal) + + super.init() + + self.clipsToBounds = true + + self.addSubnode(self.minimizedBackgroundNode) + self.minimizedBackgroundNode.addSubnode(self.minimizedTitleNode) + self.minimizedBackgroundNode.addSubnode(self.minimizedCloseButton) + + self.minimizedCloseButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: .touchUpInside) + + applySmoothRoundedCorners(self.minimizedBackgroundNode.layer) + } + + deinit { + self.minimizedTitleDisposable?.dispose() + } + + override func didLoad() { + super.didLoad() + + self.minimizedBackgroundNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.maximizeTapGesture(_:)))) + } + + @objc private func closePressed() { + self.requestClose() + } + + @objc private func maximizeTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + let location = recognizer.location(in: self.view) + if location.x < 48.0 { + self.requestClose() + } else { + self.requestMaximize() + } + } + } + + func update(size: CGSize, insets: UIEdgeInsets, transition: ContainedViewLayoutTransition) { + self.validLayout = (size, insets) + + let headerHeight: CGFloat = 44.0 + let titleSideInset: CGFloat = 56.0 + insets.left + + self.minimizedTitleNode.attributedText = NSAttributedString(string: title ?? "", font: Font.bold(17.0), textColor: self.theme.navigationBar.primaryTextColor) + + let titleSize = self.minimizedTitleNode.updateLayout(CGSize(width: size.width - titleSideInset * 2.0, height: headerHeight)) + let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: floorToScreenPixels((headerHeight - titleSize.height) / 2.0)), size: titleSize) + self.minimizedTitleNode.bounds = CGRect(origin: .zero, size: titleFrame.size) + transition.updatePosition(node: self.minimizedTitleNode, position: titleFrame.center) + transition.updateFrame(node: self.minimizedCloseButton, frame: CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 44.0, height: 44.0))) + + transition.updateFrame(node: self.minimizedBackgroundNode, frame: CGRect(origin: .zero, size: CGSize(width: size.width, height: 243.0))) + } +} diff --git a/submodules/TelegramUI/Components/MinimizedContainer/Sources/MinimizedContainer.swift b/submodules/TelegramUI/Components/MinimizedContainer/Sources/MinimizedContainer.swift new file mode 100644 index 0000000000..2ce18364d9 --- /dev/null +++ b/submodules/TelegramUI/Components/MinimizedContainer/Sources/MinimizedContainer.swift @@ -0,0 +1,926 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramPresentationData +import ComponentFlow +import AccountContext + +private let minimizedNavigationHeight: CGFloat = 44.0 +private let minimizedTopMargin: CGFloat = 3.0 + +final class ScrollViewImpl: UIScrollView { + var passthrough = false + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + if result === self && self.passthrough { + return nil + } + return result + } +} + +public class MinimizedContainerImpl: ASDisplayNode, MinimizedContainer, ASScrollViewDelegate, ASGestureRecognizerDelegate { + final class Item { + let id: AnyHashable + let controller: ViewController + + init(id: AnyHashable, controller: ViewController) { + self.id = id + self.controller = controller + } + } + + final class ItemNode: ASDisplayNode { + var theme: PresentationTheme { + didSet { + if self.theme !== oldValue { + self.headerNode.theme = NavigationControllerTheme(presentationTheme: self.theme) + } + } + } + + let item: Item + private let containerNode: ASDisplayNode + private let headerNode: MinimizedHeaderNode + private let dimCoverNode: ASDisplayNode + private let shadowNode: ASImageNode + + var tapped: (() -> Void)? + var highlighted: ((Bool) -> Void)? + var closeTapped: (() -> Void)? + + var isCovered: Bool = false { + didSet { + let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) + transition.updateAlpha(node: self.dimCoverNode, alpha: self.isCovered ? 0.25 : 0.0) + } + } + var isExpanded = false + + private var validLayout: (CGSize, UIEdgeInsets)? + + init(theme: PresentationTheme, strings: PresentationStrings, item: Item) { + self.theme = theme + self.item = item + + self.shadowNode = ASImageNode() + self.shadowNode.clipsToBounds = true + self.shadowNode.cornerRadius = 10.0 + self.shadowNode.displaysAsynchronously = false + self.shadowNode.displayWithoutProcessing = true + self.shadowNode.contentMode = .scaleToFill + self.shadowNode.isUserInteractionEnabled = false + + self.containerNode = ASDisplayNode() + self.containerNode.isUserInteractionEnabled = false + self.containerNode.cornerRadius = 10.0 + + self.headerNode = MinimizedHeaderNode(theme: NavigationControllerTheme(presentationTheme: theme), strings: strings) + self.headerNode.layer.allowsGroupOpacity = true + + self.dimCoverNode = ASDisplayNode() + self.dimCoverNode.alpha = 0.0 + self.dimCoverNode.backgroundColor = UIColor.black + self.dimCoverNode.isUserInteractionEnabled = false + + super.init() + + self.clipsToBounds = true + self.cornerRadius = 10.0 + applySmoothRoundedCorners(self.layer) + applySmoothRoundedCorners(self.containerNode.layer) + + self.shadowNode.image = shadowImage + + self.addSubnode(self.containerNode) + self.containerNode.addSubnode(self.item.controller.displayNode) + self.addSubnode(self.headerNode) + self.addSubnode(self.dimCoverNode) + self.addSubnode(self.shadowNode) + + self.headerNode.requestClose = { [weak self] in + if let self { + self.closeTapped?() + } + } + + self.headerNode.requestMaximize = { [weak self] in + if let self { + self.tapped?() + } + } + + self.headerNode.controllers = [item.controller] + } + + func setTitleControllers(_ controllers: [ViewController]?) { + self.headerNode.controllers = controllers ?? [self.item.controller] + } + + func animateIn() { + self.headerNode.alpha = 0.0 + let alphaTransition = ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut) + alphaTransition.updateAlpha(node: self.headerNode, alpha: 1.0) + } + + private var isDismissed = false + func animateOut() { + self.isDismissed = true + let transition = ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut) + transition.updateAlpha(node: self.headerNode, alpha: 0.0) + transition.updateAlpha(node: self.shadowNode, alpha: 0.0) + } + + override func didLoad() { + super.didLoad() + + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) + recognizer.tapActionAtPoint = { point in + return .waitForSingleTap + } + recognizer.highlight = { [weak self] point in + if let point = point, point.x > 280.0 { + self?.highlighted?(true) + } else { + self?.highlighted?(false) + } + } + self.view.addGestureRecognizer(recognizer) + } + + @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + guard let (_, insets) = self.validLayout else { + return + } + switch recognizer.state { + case .ended: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { + switch gesture { + case .tap: + if location.x < insets.left + minimizedNavigationHeight && location.y < minimizedNavigationHeight { + self.closeTapped?() + } else { + self.tapped?() + } + default: + break + } + } + default: + break + } + } + + func updateLayout(size: CGSize, insets: UIEdgeInsets, transition: ContainedViewLayoutTransition) { + self.validLayout = (size, insets) + + var topInset = insets.top + if size.width < size.height { + topInset += 10.0 + } + self.containerNode.frame = CGRect(origin: .zero, size: size) + self.containerNode.subnodeTransform = CATransform3DMakeTranslation(0.0, -topInset, 0.0) + + self.shadowNode.frame = CGRect(origin: .zero, size: CGSize(width: size.width, height: size.height - topInset)) + + var navigationHeight: CGFloat = minimizedNavigationHeight + if !self.isExpanded { + navigationHeight += insets.bottom + } + + let headerFrame = CGRect(origin: .zero, size: CGSize(width: size.width, height: navigationHeight)) + self.headerNode.update(size: size, insets: insets, transition: transition) + transition.updateFrame(node: self.headerNode, frame: headerFrame) + transition.updateFrame(node: self.dimCoverNode, frame: CGRect(origin: .zero, size: size)) + + if !self.isDismissed { + transition.updateAlpha(node: self.shadowNode, alpha: self.isExpanded ? 1.0 : 0.0) + } + } + } + + private let context: AccountContext + private weak var navigationController: NavigationController? + private var items: [Item] = [] + + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + var isExpanded: Bool = false + public var willMaximize: (() -> Void)? + + private let bottomEdgeView: UIImageView + private let blurView: BlurView + private let dimView: UIView + private let scrollView: ScrollViewImpl + private var itemNodes: [AnyHashable: ItemNode] = [:] + + private var highlightedItemId: AnyHashable? + + private var dismissGestureRecognizer: UIPanGestureRecognizer? + private var dismissingItemId: AnyHashable? + private var dismissingItemOffset: CGFloat? + + private var currentTransition: Transition? + private var validLayout: ContainerViewLayout? + + public init(context: AccountContext, navigationController: NavigationController) { + self.context = context + self.navigationController = navigationController + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + + self.bottomEdgeView = UIImageView() + self.bottomEdgeView.contentMode = .scaleToFill + self.bottomEdgeView.image = generateImage(CGSize(width: 22.0, height: 24.0), rotatedContext: { size, context in + context.setFillColor(UIColor.black.cgColor) + context.fill(CGRect(origin: .zero, size: size)) + + context.setBlendMode(.clear) + context.setFillColor(UIColor.clear.cgColor) + + let path = UIBezierPath(roundedRect: CGRect(x: 0, y: -10, width: 22, height: 20), cornerRadius: 10) + context.addPath(path.cgPath) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: 11, topCapHeight: 12) + + self.blurView = BlurView(effect: nil) + self.dimView = UIView() + self.dimView.alpha = 0.0 + self.dimView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6) + self.dimView.isUserInteractionEnabled = false + + self.scrollView = ScrollViewImpl() + self.scrollView.contentInsetAdjustmentBehavior = .never + self.scrollView.alwaysBounceVertical = true + + super.init() + + self.view.addSubview(self.bottomEdgeView) + self.view.addSubview(self.blurView) + self.view.addSubview(self.dimView) + self.view.addSubview(self.scrollView) + + self.presentationDataDisposable = (self.context.sharedContext.presentationData + |> deliverOnMainQueue).startStrict(next: { [weak self] presentationData in + guard let self else { + return + } + self.presentationData = presentationData + }) + } + + deinit { + self.presentationDataDisposable?.dispose() + } + + public override func didLoad() { + super.didLoad() + + self.scrollView.delegate = self.wrappedScrollViewDelegate + self.scrollView.alwaysBounceVertical = true + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + + let dismissGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.dismissPan(_:))) + dismissGestureRecognizer.delegate = self.wrappedGestureRecognizerDelegate + dismissGestureRecognizer.delaysTouchesBegan = true + self.scrollView.addGestureRecognizer(dismissGestureRecognizer) + self.dismissGestureRecognizer = dismissGestureRecognizer + } + + func item(at y: CGFloat) -> Int? { + guard let layout = self.validLayout else { + return nil + } + + let insets = layout.insets(options: [.statusBar]) + let itemCount = self.items.count + let spacing = interitemSpacing(itemCount: itemCount, boundingSize: self.scrollView.bounds.size, insets: insets) + return max(0, min(Int(floor((y - additionalInsetTop) / spacing)), itemCount - 1)) + } + + public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { + return false + } + + let location = panGesture.location(in: gestureRecognizer.view) + let velocity = panGesture.velocity(in: gestureRecognizer.view) + + if abs(velocity.x) > abs(velocity.y), let _ = self.item(at: location.y) { + return true + } + return false + } + + @objc func dismissPan(_ gestureRecognizer: UIPanGestureRecognizer) { + let scrollView = self.scrollView + + switch gestureRecognizer.state { + case .began: + let location = gestureRecognizer.location(in: scrollView) + guard let item = self.item(at: location.y) else { return } + + self.dismissingItemId = self.items[item].id + case .changed: + guard let _ = self.dismissingItemId else { return } + + var delta = gestureRecognizer.translation(in: scrollView) + delta.y = 0 + + if let offset = self.dismissingItemOffset { + self.dismissingItemOffset = offset + delta.x + } else { + self.dismissingItemOffset = delta.x + } + + gestureRecognizer.setTranslation(.zero, in: scrollView) + + self.requestUpdate(transition: .immediate) + case .ended: + var needsLayout = true + if let itemId = self.dismissingItemId { + if let offset = self.dismissingItemOffset { + if offset < -self.frame.width / 4.0 { + self.currentTransition = .dismiss(itemId: itemId) + + self.items.removeAll(where: { $0.id == itemId }) + if self.items.count == 1 { + self.isExpanded = false + self.willMaximize?() + needsLayout = false + } + } + self.dismissingItemOffset = nil + self.dismissingItemId = nil + } + } + if needsLayout { + self.requestUpdate(transition: .animated(duration: 0.4, curve: .spring)) + } + case .cancelled, .failed: + self.dismissingItemId = nil + default: + break + } + } + + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + if result === self.view { + return nil + } + return result + } + + public func addController(_ viewController: ViewController, transition: ContainedViewLayoutTransition) { + let item = Item( + id: AnyHashable(Int64.random(in: Int64.min ... Int64.max)), + controller: viewController + ) + self.items.append(item) + + self.currentTransition = .minimize(itemId: item.id) + self.requestUpdate(transition: transition) + } + + private enum Transition: Equatable { + case minimize(itemId: AnyHashable) + case maximize(itemId: AnyHashable) + case dismiss(itemId: AnyHashable) + case dismissAll + + func matches(item: Item) -> Bool { + switch self { + case .minimize: + return false + case let .maximize(itemId), let .dismiss(itemId): + return item.id == itemId + case .dismissAll: + return true + } + } + } + + public func maximizeController(_ viewController: ViewController, animated: Bool, completion: @escaping (Bool) -> Void) { + guard let item = self.items.first(where: { $0.controller === viewController }) else { + completion(self.items.count == 0) + return + } + if !animated { + self.items.removeAll(where: { $0.id == item.id }) + self.itemNodes[item.id]?.removeFromSupernode() + self.itemNodes[item.id] = nil + completion(self.items.count == 0) + self.scrollView.contentOffset = .zero + return + } + self.isExpanded = false + self.currentTransition = .maximize(itemId: item.id) + self.requestUpdate(transition: .animated(duration: 0.4, curve: .spring), completion: { [weak self] _ in + guard let self else { + return + } + completion(self.items.count == 0) + self.scrollView.contentOffset = .zero + }) + self.items.removeAll(where: { $0.id == item.id }) + } + + public func dismissAll(completion: @escaping () -> Void) { + self.currentTransition = .dismissAll + self.requestUpdate(transition: .animated(duration: 0.4, curve: .spring), completion: { _ in + completion() + }) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard self.isExpanded else { + return + } + self.requestUpdate(transition: .immediate) + } + + private func requestUpdate(transition: ContainedViewLayoutTransition, completion: @escaping (Transition) -> Void = { _ in }) { + guard let layout = self.validLayout else { + return + } + self.updateLayout(layout, transition: transition, completion: completion) + } + + public func updateLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + self.updateLayout(layout, transition: transition, completion: { _ in }) + } + + private func updateLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition, completion: @escaping (Transition) -> Void = { _ in }) { + let isFirstTime = self.validLayout == nil + var containerTransition = transition + if isFirstTime { + containerTransition = .immediate + } + + self.validLayout = layout + + let bounds = CGRect(origin: .zero, size: layout.size) + + containerTransition.updateFrame(view: self.blurView, frame: bounds) + containerTransition.updateFrame(view: self.dimView, frame: bounds) + if self.isExpanded { + if self.blurView.effect == nil { + UIView.animate(withDuration: 0.25, animations: { + self.blurView.effect = UIBlurEffect(style: self.presentationData.theme.overallDarkAppearance ? .dark : .light) + self.dimView.alpha = 1.0 + }) + } + } else { + if self.blurView.effect != nil { + UIView.animate(withDuration: 0.25, animations: { + self.blurView.effect = nil + self.dimView.alpha = 0.0 + }) + } + } + self.blurView.isUserInteractionEnabled = self.isExpanded + + let bottomEdgeHeight = 24.0 + 33.0 + layout.intrinsicInsets.bottom + let bottomEdgeOrigin = layout.size.height - bottomEdgeHeight + containerTransition.updateFrame(view: self.bottomEdgeView, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - bottomEdgeHeight), size: CGSize(width: layout.size.width, height: bottomEdgeHeight))) + + if isFirstTime { + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + transition.animatePosition(layer: self.bottomEdgeView.layer, from: self.bottomEdgeView.layer.position.offsetBy(dx: 0.0, dy: minimizedNavigationHeight + minimizedTopMargin), to: self.bottomEdgeView.layer.position) + } + + let insets = layout.insets(options: [.statusBar]) + let itemInsets = UIEdgeInsets(top: insets.top, left: layout.safeInsets.left, bottom: insets.bottom, right: layout.safeInsets.right) + var topInset = insets.top + if layout.size.width < layout.size.height { + topInset += 10.0 + } + + var index = 0 + let contentHeight = frameForIndex(index: self.items.count - 1, size: layout.size, insets: itemInsets, itemCount: self.items.count, boundingSize: layout.size).midY - 70.0 + for item in self.items { + if let currentTransition = self.currentTransition { + if currentTransition.matches(item: item) { + continue + } else if case .dismiss = currentTransition, self.items.count == 1 { + continue + } + } + + var itemTransition = containerTransition + + let itemNode: ItemNode + if let current = self.itemNodes[item.id] { + itemNode = current + itemNode.theme = self.presentationData.theme + } else { + itemTransition = .immediate + itemNode = ItemNode(theme: self.presentationData.theme, strings: self.presentationData.strings, item: item) + self.scrollView.addSubnode(itemNode) + self.itemNodes[item.id] = itemNode + } + itemNode.closeTapped = { [weak self] in + guard let self else { + return + } + if self.isExpanded { + var needsLayout = true + self.currentTransition = .dismiss(itemId: item.id) + + self.items.removeAll(where: { $0.id == item.id }) + if self.items.count == 1 { + self.isExpanded = false + self.willMaximize?() + needsLayout = false + } + if needsLayout { + self.requestUpdate(transition: .animated(duration: 0.4, curve: .spring)) + } + } else { + self.navigationController?.dismissMinimizedControllers(animated: true) + } + } + itemNode.tapped = { [weak self] in + guard let self else { + return + } + if self.isExpanded { + self.navigationController?.maximizeViewController(item.controller, animated: true) + } else { + if self.items.count == 1 { + self.navigationController?.maximizeViewController(item.controller, animated: true) + } else { + self.isExpanded = true + self.requestUpdate(transition: .animated(duration: 0.4, curve: .spring)) + } + } + } + + let itemFrame: CGRect + let itemTransform: CATransform3D + + if index == self.items.count - 1 { + itemNode.layer.zPosition = 10000.0 + } else { + itemNode.layer.zPosition = 0.0 + } + itemNode.isExpanded = self.isExpanded + + if self.isExpanded { + let currentItemFrame = frameForIndex(index: index, size: layout.size, insets: itemInsets, itemCount: self.items.count, boundingSize: layout.size) + let currentItemTransform = final3dTransform(for: currentItemFrame.minY, size: currentItemFrame.size, contentHeight: contentHeight, itemCount: self.items.count, additionalAngle: self.highlightedItemId == item.id ? 0.04 : nil, scrollBounds: self.scrollView.bounds, insets: itemInsets) + + var effectiveItemFrame = currentItemFrame + var effectiveItemTransform = currentItemTransform + + if let dismissingItemId = self.dismissingItemId, let deletingIndex = self.items.firstIndex(where: { $0.id == dismissingItemId }), let offset = self.dismissingItemOffset { + var targetItemFrame: CGRect? + var targetItemTransform: CATransform3D? + if deletingIndex == index { + let effectiveOffset: CGFloat + if offset <= 0.0 { + effectiveOffset = offset + } else { + effectiveOffset = scrollingRubberBandingOffset(offset: offset, bandingStart: 0.0, range: 20.0) + } + effectiveItemFrame = effectiveItemFrame.offsetBy(dx: effectiveOffset, dy: 0.0) + } else if index < deletingIndex { + let frame = frameForIndex(index: index, size: layout.size, insets: itemInsets, itemCount: self.items.count - 1, boundingSize: layout.size) + let spacing = interitemSpacing(itemCount: self.items.count - 1, boundingSize: layout.size, insets: itemInsets) + + targetItemFrame = frame + targetItemTransform = final3dTransform(for: frame.minY, size: layout.size, contentHeight: contentHeight - layout.size.height - spacing, itemCount: self.items.count - 1, scrollBounds: self.scrollView.bounds, insets: itemInsets) + } else { + let frame = frameForIndex(index: index - 1, size: layout.size, insets: itemInsets, itemCount: self.items.count - 1, boundingSize: layout.size) + let spacing = interitemSpacing(itemCount: self.items.count - 1, boundingSize: layout.size, insets: itemInsets) + + targetItemFrame = frame + targetItemTransform = final3dTransform(for: frame.minY, size: layout.size, contentHeight: contentHeight - layout.size.height - spacing, itemCount: self.items.count - 1, scrollBounds: self.scrollView.bounds, insets: itemInsets) + } + + if let targetItemFrame, let targetItemTransform { + let fraction = max(0.0, min(1.0, -1.0 * offset / (layout.size.width * 1.5))) + effectiveItemFrame = effectiveItemFrame.interpolate(with: targetItemFrame, fraction: fraction) + effectiveItemTransform = effectiveItemTransform.interpolate(with: targetItemTransform, fraction: fraction) + } + } + itemFrame = effectiveItemFrame + itemTransform = effectiveItemTransform + + itemNode.isCovered = false + } else { + var itemOffset: CGFloat = bottomEdgeOrigin + 13.0 + var hideTransform = false + if let currentTransition = self.currentTransition { + if case let .maximize(itemId) = currentTransition { + itemOffset += layout.size.height * 0.25 + if let lastItemNode = self.scrollView.subviews.last?.asyncdisplaykit_node as? ItemNode, lastItemNode.item.id == itemId { + hideTransform = true + } + } else if case .dismiss = currentTransition, self.items.count == 1 { + itemOffset += layout.size.height * 0.25 + } + } + + var effectiveItemFrame = CGRect(origin: CGPoint(x: 0.0, y: itemOffset), size: layout.size) + var effectiveItemTransform = itemNode.transform + if hideTransform { + effectiveItemTransform = CATransform3DMakeScale(0.7, 0.7, 1.0) + } else if index == self.items.count - 1 { + if self.items.count > 1 { + effectiveItemFrame = effectiveItemFrame.offsetBy(dx: 0.0, dy: 4.0) + } + effectiveItemTransform = CATransform3DIdentity + } else { + let sideInset: CGFloat = 10.0 + let scaledWidth = layout.size.width - sideInset * 2.0 + let scale = scaledWidth / layout.size.width + let scaledHeight = layout.size.height * scale + let verticalOffset = layout.size.height - scaledHeight + effectiveItemFrame = effectiveItemFrame.offsetBy(dx: 0.0, dy: -verticalOffset / 2.0) + effectiveItemTransform = CATransform3DMakeScale(scale, scale, 1.0) + } + itemFrame = effectiveItemFrame + itemTransform = effectiveItemTransform + + itemNode.isCovered = index == self.items.count - 2 + } + + itemNode.bounds = CGRect(origin: .zero, size: itemFrame.size) + itemNode.updateLayout(size: layout.size, insets: itemInsets, transition: itemTransition) + + if index == self.items.count - 1 && !self.isExpanded { + itemNode.setTitleControllers(self.items.map { $0.controller }) + } else { + itemNode.setTitleControllers(nil) + } + + itemTransition.updateTransform(node: itemNode, transform: itemTransform) + itemTransition.updatePosition(node: itemNode, position: itemFrame.center) + + index += 1 + } + + let contentSize = CGSize(width: layout.size.width, height: contentHeight) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + if self.scrollView.frame != bounds { + self.scrollView.frame = bounds + } + self.scrollView.passthrough = !self.isExpanded + self.scrollView.isScrollEnabled = self.isExpanded + + if let currentTransition = self.currentTransition { + switch self.currentTransition { + case let .minimize(itemId): + guard let itemNode = self.itemNodes[itemId] else { + return + } + + let dimView = UIView() + dimView.alpha = 1.0 + dimView.frame = CGRect(origin: .zero, size: layout.size) + dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.25) + self.view.insertSubview(dimView, aboveSubview: self.blurView) + dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + dimView.removeFromSuperview() + }) + + + itemNode.animateIn() + + var initialOffset = insets.top + itemNode.item.controller.minimizedTopEdgeOffset + if layout.size.width < layout.size.height { + initialOffset += 10.0 + } + if let minimizedBounds = itemNode.item.controller.minimizedBounds { + initialOffset += -minimizedBounds.minY + } + + transition.animatePosition(node: itemNode, from: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0 + initialOffset), completion: { _ in + if self.currentTransition == currentTransition { + self.currentTransition = nil + } + completion(currentTransition) + }) + case let .maximize(itemId): + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + guard let itemNode = self.itemNodes[itemId] else { + return + } + + let dimView = UIView() + dimView.frame = CGRect(origin: .zero, size: layout.size) + dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.25) + self.view.insertSubview(dimView, aboveSubview: self.blurView) + dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + + itemNode.animateOut() + transition.updateTransform(node: itemNode, transform: CATransform3DIdentity) + transition.updatePosition(node: itemNode, position: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0 + topInset + self.scrollView.contentOffset.y), completion: { _ in + if self.currentTransition == currentTransition { + self.currentTransition = nil + } + completion(currentTransition) + self.itemNodes[itemId] = nil + itemNode.removeFromSupernode() + dimView.removeFromSuperview() + + self.requestUpdate(transition: .immediate) + }) + case let .dismiss(itemId): + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + guard let dismissedItemNode = self.itemNodes[itemId] else { + return + } + if self.items.count == 1 { + if let itemNode = self.itemNodes.first(where: { $0.0 != itemId })?.value { + let dimView = UIView() + dimView.frame = CGRect(origin: .zero, size: layout.size) + dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.25) + self.view.insertSubview(dimView, aboveSubview: self.blurView) + dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + + itemNode.animateOut() + transition.updateTransform(node: itemNode, transform: CATransform3DIdentity) + transition.updatePosition(node: itemNode, position: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0 + topInset + self.scrollView.contentOffset.y), completion: { _ in + if self.currentTransition == currentTransition { + self.currentTransition = nil + } + completion(currentTransition) + self.itemNodes[itemId] = nil + itemNode.removeFromSupernode() + dimView.removeFromSuperview() + + self.navigationController?.maximizeViewController(itemNode.item.controller, animated: false) + + self.requestUpdate(transition: .immediate) + }) + } + transition.updatePosition(node: dismissedItemNode, position: CGPoint(x: -layout.size.width, y: dismissedItemNode.position.y)) + } else { + transition.updatePosition(node: dismissedItemNode, position: CGPoint(x: -layout.size.width, y: dismissedItemNode.position.y), completion: { _ in + if self.currentTransition == currentTransition { + self.currentTransition = nil + } + completion(currentTransition) + + self.itemNodes[itemId] = nil + dismissedItemNode.removeFromSupernode() + }) + } + case .dismissAll: + let dismissOffset = collapsedHeight(layout: layout) + transition.updatePosition(layer: self.bottomEdgeView.layer, position: self.bottomEdgeView.layer.position.offsetBy(dx: 0.0, dy: dismissOffset), completion: { _ in + if self.currentTransition == currentTransition { + self.currentTransition = nil + } + completion(currentTransition) + }) + transition.updatePosition(layer: self.scrollView.layer, position: self.scrollView.center.offsetBy(dx: 0.0, dy: dismissOffset)) + default: + break + } + } + } + + public func collapsedHeight(layout: ContainerViewLayout) -> CGFloat { + return minimizedNavigationHeight + minimizedTopMargin + layout.intrinsicInsets.bottom + } +} + +private let maxInteritemSpacing: CGFloat = 240.0 +private let additionalInsetTop: CGFloat = 16.0 +private let additionalInsetBottom: CGFloat = 0.0 +private let zOffset: CGFloat = -60.0 + +private let perspectiveCorrection: CGFloat = -1.0 / 1000.0 +private let maxRotationAngle: CGFloat = -CGFloat.pi / 2.2 + +private func angle(for origin: CGFloat, itemCount: Int, scrollBounds: CGRect, contentHeight: CGFloat?, insets: UIEdgeInsets) -> CGFloat { + var rotationAngle = rotationAngleAt0(itemCount: itemCount) + + var contentOffset = scrollBounds.origin.y + if contentOffset < 0.0 { + contentOffset *= 2.0 + } + + var yOnScreen = origin - contentOffset - additionalInsetTop - insets.top + if yOnScreen < 0 { + yOnScreen = 0 + } else if yOnScreen > scrollBounds.height { + yOnScreen = scrollBounds.height + } + + let maxRotationVariance = maxRotationAngle - rotationAngleAt0(itemCount: itemCount) + rotationAngle += (maxRotationVariance / scrollBounds.height) * yOnScreen + + return rotationAngle +} + +private func final3dTransform(for origin: CGFloat, size: CGSize, contentHeight: CGFloat?, itemCount: Int, forcedAngle: CGFloat? = nil, additionalAngle: CGFloat? = nil, scrollBounds: CGRect, insets: UIEdgeInsets) -> CATransform3D { + var transform = CATransform3DIdentity + transform.m34 = perspectiveCorrection + + let rotationAngle = forcedAngle ?? angle(for: origin, itemCount: itemCount, scrollBounds: scrollBounds, contentHeight: contentHeight, insets: insets) + var effectiveRotationAngle = rotationAngle + if let additionalAngle = additionalAngle { + effectiveRotationAngle += additionalAngle + } + + let r = size.height / 2.0 + abs(zOffset / sin(rotationAngle)) + + let zTranslation = r * sin(rotationAngle) + let yTranslation: CGFloat = r * (1 - cos(rotationAngle)) + + let zTranslateTransform = CATransform3DTranslate(transform, 0.0, -yTranslation, zTranslation) + + let rotateTransform = CATransform3DRotate(zTranslateTransform, effectiveRotationAngle, 1.0, 0.0, 0.0) + + return rotateTransform +} + +private func interitemSpacing(itemCount: Int, boundingSize: CGSize, insets: UIEdgeInsets) -> CGFloat { + var interitemSpacing = maxInteritemSpacing + if itemCount > 0 { + interitemSpacing = (boundingSize.height - additionalInsetTop - additionalInsetBottom - insets.top) / CGFloat(min(itemCount, 5)) + } + return interitemSpacing +} + +private func frameForIndex(index: Int, size: CGSize, insets: UIEdgeInsets, itemCount: Int, boundingSize: CGSize) -> CGRect { + let spacing = interitemSpacing(itemCount: itemCount, boundingSize: boundingSize, insets: insets) + let y = additionalInsetTop + insets.top + spacing * CGFloat(index) + let origin = CGPoint(x: insets.left, y: y) + + return CGRect(origin: origin, size: CGSize(width: size.width - insets.left - insets.right, height: size.height)) +} + +private func rotationAngleAt0(itemCount: Int) -> CGFloat { + let multiplier: CGFloat = min(CGFloat(itemCount), 5.0) - 1.0 + return -CGFloat.pi / 7.0 - CGFloat.pi / 7.0 * multiplier / 4.0 +} + +private class BlurView: UIVisualEffectView { + private func setup() { + for subview in self.subviews { + if subview.description.contains("VisualEffectSubview") { + subview.isHidden = true + } + } + + if let sublayer = self.layer.sublayers?[0], let filters = sublayer.filters { + sublayer.backgroundColor = nil + sublayer.isOpaque = false + let allowedKeys: [String] = [ + "gaussianBlur", + "colorSaturate" + ] + sublayer.filters = filters.filter { filter in + guard let filter = filter as? NSObject else { + return true + } + let filterName = String(describing: filter) + if !allowedKeys.contains(filterName) { + return false + } + return true + } + } + } + + override var effect: UIVisualEffect? { + get { + return super.effect + } + set { + super.effect = newValue + self.setup() + } + } + + override func didAddSubview(_ subview: UIView) { + super.didAddSubview(subview) + self.setup() + } +} + +private let shadowImage: UIImage? = { + return generateImage(CGSize(width: 1.0, height: 480.0), rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + let gradientColors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.55).cgColor, UIColor.black.withAlphaComponent(0.55).cgColor] as CFArray + + var locations: [CGFloat] = [0.0, 0.65, 1.0] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: bounds.height), options: []) + }) +}() diff --git a/submodules/TelegramUI/Components/MinimizedContainer/Sources/Utils.swift b/submodules/TelegramUI/Components/MinimizedContainer/Sources/Utils.swift new file mode 100644 index 0000000000..47e004f47a --- /dev/null +++ b/submodules/TelegramUI/Components/MinimizedContainer/Sources/Utils.swift @@ -0,0 +1,52 @@ +import Foundation +import UIKit + +extension CATransform3D { + func interpolate(with other: CATransform3D, fraction: CGFloat) -> CATransform3D { + var vectors = Array(repeating: 0.0, count: 16) + vectors[0] = self.m11 + (other.m11 - self.m11) * fraction + vectors[1] = self.m12 + (other.m12 - self.m12) * fraction + vectors[2] = self.m13 + (other.m13 - self.m13) * fraction + vectors[3] = self.m14 + (other.m14 - self.m14) * fraction + vectors[4] = self.m21 + (other.m21 - self.m21) * fraction + vectors[5] = self.m22 + (other.m22 - self.m22) * fraction + vectors[6] = self.m23 + (other.m23 - self.m23) * fraction + vectors[7] = self.m24 + (other.m24 - self.m24) * fraction + vectors[8] = self.m31 + (other.m31 - self.m31) * fraction + vectors[9] = self.m32 + (other.m32 - self.m32) * fraction + vectors[10] = self.m33 + (other.m33 - self.m33) * fraction + vectors[11] = self.m34 + (other.m34 - self.m34) * fraction + vectors[12] = self.m41 + (other.m41 - self.m41) * fraction + vectors[13] = self.m42 + (other.m42 - self.m42) * fraction + vectors[14] = self.m43 + (other.m43 - self.m43) * fraction + vectors[15] = self.m44 + (other.m44 - self.m44) * fraction + + return CATransform3D(m11: vectors[0], m12: vectors[1], m13: vectors[2], m14: vectors[3], m21: vectors[4], m22: vectors[5], m23: vectors[6], m24: vectors[7], m31: vectors[8], m32: vectors[9], m33: vectors[10], m34: vectors[11], m41: vectors[12], m42: vectors[13], m43: vectors[14], m44: vectors[15]) + } +} + +private extension CGFloat { + func interpolate(with other: CGFloat, fraction: CGFloat) -> CGFloat { + let invT = 1.0 - fraction + let result = other * fraction + self * invT + return result + } +} + +private extension CGPoint { + func interpolate(with other: CGPoint, fraction: CGFloat) -> CGPoint { + return CGPoint(x: self.x.interpolate(with: other.x, fraction: fraction), y: self.y.interpolate(with: other.y, fraction: fraction)) + } +} + +private extension CGSize { + func interpolate(with other: CGSize, fraction: CGFloat) -> CGSize { + return CGSize(width: self.width.interpolate(with: other.width, fraction: fraction), height: self.height.interpolate(with: other.height, fraction: fraction)) + } +} + +extension CGRect { + func interpolate(with other: CGRect, fraction: CGFloat) -> CGRect { + return CGRect(origin: self.origin.interpolate(with: other.origin, fraction: fraction), size: self.size.interpolate(with: other.size, fraction: fraction)) + } +} diff --git a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift index acb58ac31c..3808503034 100644 --- a/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsPurchaseScreen/Sources/StarsPurchaseScreen.swift @@ -229,7 +229,7 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { let textString: String if let _ = context.component.requiredStars { - textString = strings.Stars_Purchase_StarsNeededInfo(state.peer?.compactDisplayTitle ?? "").string + textString = state.peer == nil ? strings.Stars_Purchase_StarsNeededUnlockInfo : strings.Stars_Purchase_StarsNeededInfo(state.peer?.compactDisplayTitle ?? "").string } else { textString = strings.Stars_Purchase_GetStarsInfo } @@ -310,11 +310,11 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { minimumCount = requiredStars - balance } for product in products { - if let minimumCount, minimumCount > product.option.count { + if let minimumCount, minimumCount > product.option.count && !(items.isEmpty && product.id == products.last?.id) { continue } - if let _ = minimumCount, items.isEmpty { + if let _ = minimumCount, items.isEmpty { } else if !context.component.expanded && !initialValues.contains(product.option.count) { continue @@ -381,7 +381,7 @@ private final class StarsPurchaseScreenContentComponent: CombinedComponent { } } - if !context.component.expanded { + if !context.component.expanded && items.count > 1 { let titleComponent = AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.Stars_Purchase_ShowMore, diff --git a/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift index 6d81c79dd7..632e45c61f 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransferScreen/Sources/StarsTransferScreen.swift @@ -213,7 +213,16 @@ private final class SheetContent: CombinedComponent { } |> take(1) |> deliverOnMainQueue).start(next: { _ in - action() + Queue.mainQueue().after(0.1, { [weak self] in + if let self, let balance = self.balance, balance < self.invoice.totalAmount { + self.inProgress = false + self.updated() + + self.buy(requestTopUp: requestTopUp, completion: completion) + } else { + action() + } + }) }) }) } @@ -437,7 +446,8 @@ private final class SheetContent: CombinedComponent { state.cachedStarImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/PremiumIcon"), color: .white)!, theme) } - let buttonAttributedString = NSMutableAttributedString(string: "\(strings.Stars_Transfer_Pay) # \(amount)", font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center) + let amountString = presentationStringsFormattedNumber(Int32(amount), presentationData.dateTimeFormat.groupingSeparator) + let buttonAttributedString = NSMutableAttributedString(string: "\(strings.Stars_Transfer_Pay) # \(amountString)", font: Font.semibold(17.0), textColor: .white, paragraphAlignment: .center) if let range = buttonAttributedString.string.range(of: "#"), let starImage = state.cachedStarImage?.0 { buttonAttributedString.addAttribute(.attachment, value: starImage, range: NSRange(range, in: buttonAttributedString.string)) buttonAttributedString.addAttribute(.foregroundColor, value: UIColor(rgb: 0xffffff), range: NSRange(range, in: buttonAttributedString.string)) @@ -450,6 +460,7 @@ private final class SheetContent: CombinedComponent { let starsContext = component.starsContext let botTitle = state.botPeer?.compactDisplayTitle ?? "" let invoice = component.invoice + let isMedia = !component.extendedMedia.isEmpty let button = button.update( component: ButtonComponent( background: ButtonComponent.Background( @@ -472,7 +483,7 @@ private final class SheetContent: CombinedComponent { context: accountContext, starsContext: starsContext, options: state?.options ?? [], - peerId: state?.botPeer?.id, + peerId: isMedia ? nil : state?.botPeer?.id, requiredStars: invoice.totalAmount, completion: { [weak starsContext] stars in starsContext?.add(balance: stars) diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 04a5bd6d9c..58df1cff92 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -3752,6 +3752,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return state.updatedShowWebView(true).updatedForceInputCommandsHidden(true) } + let context = strongSelf.context let params = WebAppParameters(source: .menu, peerId: peerId, botId: peerId, botName: botName, url: url, queryId: nil, payload: nil, buttonText: buttonText, keepAliveSignal: nil, forceHasSettings: false) let controller = standaloneWebAppController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, params: params, threadId: strongSelf.chatLocation.threadId, openUrl: { [weak self] url, concealed, commit in self?.openUrl(url, concealed: concealed, forceExternal: true, commit: commit) @@ -3798,7 +3799,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }, getNavigationController: { [weak self] in - return self?.effectiveNavigationController + return self?.effectiveNavigationController ?? context.sharedContext.mainWindow?.viewController as? NavigationController }) controller.navigationPresentation = .flatModal strongSelf.push(controller) @@ -3823,6 +3824,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } + let context = strongSelf.context let params = WebAppParameters(source: isInline ? .inline : .simple, peerId: peerId, botId: botId, botName: botName, url: url, queryId: nil, payload: nil, buttonText: buttonText, keepAliveSignal: nil, forceHasSettings: false) let controller = standaloneWebAppController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, params: params, threadId: strongSelf.chatLocation.threadId, openUrl: { [weak self] url, concealed, commit in self?.openUrl(url, concealed: concealed, forceExternal: true, commit: commit) @@ -3843,7 +3845,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } }, getNavigationController: { [weak self] in - return self?.effectiveNavigationController + return self?.effectiveNavigationController ?? context.sharedContext.mainWindow?.viewController as? NavigationController }) controller.navigationPresentation = .flatModal strongSelf.currentWebAppController = controller @@ -3863,13 +3865,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } + let context = strongSelf.context let params = WebAppParameters(source: .generic, peerId: peerId, botId: peerId, botName: botName, url: result.url, queryId: result.queryId, payload: nil, buttonText: buttonText, keepAliveSignal: result.keepAliveSignal, forceHasSettings: false) let controller = standaloneWebAppController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, params: params, threadId: strongSelf.chatLocation.threadId, openUrl: { [weak self] url, concealed, commit in self?.openUrl(url, concealed: concealed, forceExternal: true, commit: commit) }, completion: { [weak self] in self?.chatDisplayNode.historyNode.scrollToEndOfHistory() }, getNavigationController: { [weak self] in - return self?.effectiveNavigationController + return self?.effectiveNavigationController ?? context.sharedContext.mainWindow?.viewController as? NavigationController }) controller.navigationPresentation = .flatModal strongSelf.currentWebAppController = controller @@ -8188,6 +8191,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard let strongSelf = self else { return } + let context = strongSelf.context let params = WebAppParameters(source: .generic, peerId: peerId, botId: botPeer.id, botName: botApp.title, url: url, queryId: 0, payload: payload, buttonText: "", keepAliveSignal: nil, forceHasSettings: botApp.flags.contains(.hasSettings)) let controller = standaloneWebAppController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, params: params, threadId: strongSelf.chatLocation.threadId, openUrl: { [weak self] url, concealed, commit in self?.openUrl(url, concealed: concealed, forceExternal: true, commit: commit) @@ -8210,7 +8214,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }, completion: { [weak self] in self?.chatDisplayNode.historyNode.scrollToEndOfHistory() }, getNavigationController: { [weak self] in - return self?.effectiveNavigationController + return self?.effectiveNavigationController ?? context.sharedContext.mainWindow?.viewController as? NavigationController }) controller.navigationPresentation = .flatModal strongSelf.currentWebAppController = controller diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 18220879b6..d6b2115d71 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -2196,7 +2196,11 @@ public func standaloneWebAppController( controller.getSourceRect = getSourceRect controller.title = params.botName controller.shouldMinimizeOnSwipe = { - return false + if params.source != .menu { + return true + } else { + return false + } } return controller }