mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-06-20 18:24:43 +00:00
Design and plan, then the implementation: extract a shared openInstantPageMedia helper for V1+V2 reuse, add typed media item cases + allMedias() helper, introduce InstantPageV2RenderContext and thread it through the view tree, add four media wrapper views, route layout dispatch through typed media items, let the chat bubble build the render context so real media renders, wire gallery tap routing, cache V2View by message id + stableVersion, add stable-id identity + per-class update(item:theme:), reuse item views on re-layouts via stable-id harvest, preserve TextView clipping inset on reuse, and let the use site control media-reference construction.
1963 lines
99 KiB
Swift
1963 lines
99 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Postbox
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import SafariServices
|
|
import TelegramPresentationData
|
|
import TelegramUIPreferences
|
|
import AccountContext
|
|
import SaveToCameraRoll
|
|
import GalleryUI
|
|
import OpenInExternalAppUI
|
|
import LocationUI
|
|
import UndoUI
|
|
import ContextUI
|
|
import TranslateUI
|
|
|
|
final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate {
|
|
private weak var controller: InstantPageController?
|
|
private let context: AccountContext
|
|
private var settings: InstantPagePresentationSettings?
|
|
private var themeSettings: PresentationThemeSettings?
|
|
private var presentationTheme: PresentationTheme
|
|
private var strings: PresentationStrings
|
|
private var nameDisplayOrder: PresentationPersonNameOrder
|
|
private let autoNightModeTriggered: Bool
|
|
private var dateTimeFormat: PresentationDateTimeFormat
|
|
private var theme: InstantPageTheme?
|
|
private let sourceLocation: InstantPageSourceLocation
|
|
private var manualThemeOverride: InstantPageThemeType?
|
|
private let getNavigationController: () -> NavigationController?
|
|
private let present: (ViewController, Any?) -> Void
|
|
private let pushController: (ViewController) -> Void
|
|
private let openPeer: (EnginePeer) -> Void
|
|
|
|
private var webPage: (webPage: TelegramMediaWebpage, instantPage: InstantPage?)?
|
|
private var initialAnchor: String?
|
|
private var pendingAnchor: String?
|
|
private var initialState: InstantPageStoredState?
|
|
|
|
private var containerLayout: ContainerViewLayout?
|
|
private var setupScrollOffsetOnLayout: Bool = false
|
|
|
|
private let statusBar: StatusBar
|
|
private let navigationBar: InstantPageNavigationBar
|
|
private let scrollNode: ASScrollNode
|
|
private let scrollNodeHeader: ASDisplayNode
|
|
private let scrollNodeFooter: ASDisplayNode
|
|
private var linkHighlightingNode: LinkHighlightingNode?
|
|
private var textSelectionNode: LinkHighlightingNode?
|
|
|
|
private var settingsNode: InstantPageSettingsNode?
|
|
private var settingsDimNode: ASDisplayNode?
|
|
|
|
var currentLayout: InstantPageLayout?
|
|
var currentLayoutTiles: [InstantPageTile] = []
|
|
var currentLayoutItemsWithNodes: [InstantPageItem] = []
|
|
var distanceThresholdGroupCount: [Int: Int] = [:]
|
|
|
|
var visibleTiles: [Int: InstantPageTileNode] = [:]
|
|
var visibleItemsWithNodes: [Int: InstantPageNode] = [:]
|
|
|
|
var currentWebEmbedHeights: [Int : CGFloat] = [:]
|
|
var currentExpandedDetails: [Int : Bool]?
|
|
var currentDetailsItems: [InstantPageDetailsItem] = []
|
|
private var resolvedExternalMediaDimensions: [MediaId: PixelDimensions] = [:]
|
|
private var pendingResolvedExternalMediaDimensions = Set<MediaId>()
|
|
|
|
var currentAccessibilityAreas: [AccessibilityAreaNode] = []
|
|
|
|
private var previousContentOffset: CGPoint?
|
|
private var isDeceleratingBecauseOfDragging = false
|
|
|
|
private let resolveUrlDisposable = MetaDisposable()
|
|
private let loadWebpageDisposable = MetaDisposable()
|
|
|
|
private let loadProgress = ValuePromise<CGFloat>(1.0, ignoreRepeated: true)
|
|
private let loadProgressDisposable = MetaDisposable()
|
|
|
|
private let updateLayoutDisposable = MetaDisposable()
|
|
private let updateExternalMediaDimensionsDisposable = MetaDisposable()
|
|
|
|
private var themeReferenceDate: Date?
|
|
|
|
var currentState: InstantPageStoredState {
|
|
var details: [InstantPageStoredDetailsState] = []
|
|
if let currentExpandedDetails = self.currentExpandedDetails {
|
|
for (index, expanded) in currentExpandedDetails {
|
|
details.append(InstantPageStoredDetailsState(index: Int32(clamping: index), expanded: expanded, details: []))
|
|
}
|
|
}
|
|
return InstantPageStoredState(contentOffset: Double(self.scrollNode.view.contentOffset.y), details: details)
|
|
}
|
|
|
|
init(controller: InstantPageController, context: AccountContext, settings: InstantPagePresentationSettings?, themeSettings: PresentationThemeSettings?, presentationTheme: PresentationTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, autoNightModeTriggered: Bool, statusBar: StatusBar, sourceLocation: InstantPageSourceLocation, getNavigationController: @escaping () -> NavigationController?, present: @escaping (ViewController, Any?) -> Void, pushController: @escaping (ViewController) -> Void, openPeer: @escaping (EnginePeer) -> Void, navigateBack: @escaping () -> Void) {
|
|
self.controller = controller
|
|
self.context = context
|
|
self.presentationTheme = presentationTheme
|
|
self.dateTimeFormat = dateTimeFormat
|
|
self.nameDisplayOrder = nameDisplayOrder
|
|
self.autoNightModeTriggered = autoNightModeTriggered
|
|
self.strings = strings
|
|
self.settings = settings
|
|
let themeReferenceDate = Date()
|
|
self.themeReferenceDate = themeReferenceDate
|
|
self.theme = settings.flatMap { settings in
|
|
return instantPageThemeForType(instantPageThemeTypeForSettingsAndTime(themeSettings: themeSettings, settings: settings, time: themeReferenceDate, forceDarkTheme: autoNightModeTriggered).0, settings: settings)
|
|
}
|
|
self.sourceLocation = sourceLocation
|
|
self.statusBar = statusBar
|
|
self.getNavigationController = getNavigationController
|
|
self.present = present
|
|
self.pushController = pushController
|
|
self.openPeer = openPeer
|
|
|
|
self.navigationBar = InstantPageNavigationBar(strings: strings)
|
|
self.scrollNode = ASScrollNode()
|
|
self.scrollNodeHeader = ASDisplayNode()
|
|
self.scrollNodeHeader.backgroundColor = .black
|
|
|
|
self.scrollNodeFooter = ASDisplayNode()
|
|
self.scrollNodeFooter.backgroundColor = .black
|
|
|
|
super.init()
|
|
|
|
self.setViewBlock({
|
|
return UITracingLayerView()
|
|
})
|
|
|
|
if let theme = self.theme {
|
|
self.backgroundColor = theme.pageBackgroundColor
|
|
self.scrollNodeFooter.backgroundColor = theme.panelBackgroundColor
|
|
}
|
|
self.addSubnode(self.scrollNode)
|
|
self.scrollNode.addSubnode(self.scrollNodeHeader)
|
|
self.scrollNode.addSubnode(self.scrollNodeFooter)
|
|
self.addSubnode(self.navigationBar)
|
|
self.scrollNode.view.delaysContentTouches = false
|
|
self.scrollNode.view.delegate = self.wrappedScrollViewDelegate
|
|
|
|
self.navigationBar.back = navigateBack
|
|
self.navigationBar.share = { [weak self] in
|
|
if let strongSelf = self, let (webPage, _) = strongSelf.webPage, case let .Loaded(content) = webPage.content {
|
|
let shareController = context.sharedContext.makeShareController(context: context, params: ShareControllerParams(subject: .url(content.url), actionCompleted: { [weak self] in
|
|
if let strongSelf = self {
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil)
|
|
}
|
|
}, completed: { [weak self] peerIds in
|
|
let _ = (context.engine.data.get(
|
|
EngineDataList(
|
|
peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init)
|
|
)
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] peerList in
|
|
if let strongSelf = self {
|
|
let peers = peerList.compactMap { $0 }
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
|
|
let text: String
|
|
var savedMessages = false
|
|
if peerIds.count == 1, let peerId = peerIds.first, peerId == strongSelf.context.account.peerId {
|
|
text = presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One
|
|
savedMessages = true
|
|
} else {
|
|
if peers.count == 1, let peer = peers.first {
|
|
var peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
|
|
peerName = peerName.replacingOccurrences(of: "**", with: "")
|
|
text = presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string
|
|
} else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last {
|
|
var firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
|
|
firstPeerName = firstPeerName.replacingOccurrences(of: "**", with: "")
|
|
var secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
|
|
secondPeerName = secondPeerName.replacingOccurrences(of: "**", with: "")
|
|
text = presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string
|
|
} else if let peer = peers.first {
|
|
var peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
|
|
peerName = peerName.replacingOccurrences(of: "**", with: "")
|
|
text = presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string
|
|
} else {
|
|
text = ""
|
|
}
|
|
}
|
|
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { action in
|
|
if savedMessages, let self, action == .info {
|
|
let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId))
|
|
|> deliverOnMainQueue).start(next: { [weak self] peer in
|
|
guard let self, let peer else {
|
|
return
|
|
}
|
|
guard let navigationController = self.getNavigationController() else {
|
|
return
|
|
}
|
|
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: .peer(peer), forceOpenChat: true))
|
|
})
|
|
}
|
|
return false
|
|
}), nil)
|
|
}
|
|
})
|
|
}))
|
|
strongSelf.present(shareController, nil)
|
|
}
|
|
}
|
|
self.navigationBar.settings = { [weak self] in
|
|
if let strongSelf = self {
|
|
strongSelf.presentSettings()
|
|
}
|
|
}
|
|
self.navigationBar.scrollToTop = { [weak self] in
|
|
if let strongSelf = self {
|
|
strongSelf.scrollNode.view.setContentOffset(CGPoint(x: 0.0, y: -strongSelf.scrollNode.view.contentInset.top), animated: true)
|
|
}
|
|
}
|
|
|
|
self.loadProgressDisposable.set((self.loadProgress.get()
|
|
|> deliverOnMainQueue).start(next: { [weak self] value in
|
|
self?.navigationBar.setLoadProgress(value)
|
|
}))
|
|
}
|
|
|
|
deinit {
|
|
self.resolveUrlDisposable.dispose()
|
|
self.loadWebpageDisposable.dispose()
|
|
self.loadProgressDisposable.dispose()
|
|
self.updateExternalMediaDimensionsDisposable.dispose()
|
|
}
|
|
|
|
func update(settings: InstantPagePresentationSettings, themeSettings: PresentationThemeSettings?, strings: PresentationStrings) {
|
|
if self.settings != settings || self.strings !== strings {
|
|
let previousSettings = self.settings
|
|
var updateLayout = previousSettings == nil
|
|
|
|
if let previousSettings = previousSettings {
|
|
if previousSettings.themeType != settings.themeType {
|
|
self.themeReferenceDate = nil
|
|
}
|
|
}
|
|
|
|
self.settings = settings
|
|
self.themeSettings = themeSettings
|
|
let themeType = instantPageThemeTypeForSettingsAndTime(themeSettings: self.themeSettings, settings: settings, time: self.themeReferenceDate, forceDarkTheme: self.autoNightModeTriggered)
|
|
let theme = instantPageThemeForType(themeType.0, settings: settings)
|
|
self.theme = theme
|
|
self.strings = strings
|
|
|
|
self.settingsNode?.updateSettingsAndCurrentThemeType(settings: settings, type: themeType)
|
|
|
|
var animated = false
|
|
if let previousSettings = previousSettings {
|
|
if previousSettings.themeType != settings.themeType || previousSettings.autoNightMode != settings.autoNightMode {
|
|
updateLayout = true
|
|
animated = true
|
|
}
|
|
if previousSettings.fontSize != settings.fontSize || previousSettings.lineSpacingFactor != settings.lineSpacingFactor || previousSettings.forceSerif != settings.forceSerif {
|
|
animated = false
|
|
updateLayout = true
|
|
}
|
|
}
|
|
|
|
self.backgroundColor = theme.pageBackgroundColor
|
|
|
|
if updateLayout {
|
|
if animated {
|
|
if let snapshotView = self.scrollNode.view.snapshotView(afterScreenUpdates: false) {
|
|
self.view.insertSubview(snapshotView, aboveSubview: self.scrollNode.view)
|
|
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
|
snapshotView?.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
|
|
self.updateLayout()
|
|
|
|
self.scrollNodeFooter.backgroundColor = theme.panelBackgroundColor
|
|
for (_, itemNode) in self.visibleItemsWithNodes {
|
|
itemNode.update(strings: strings, theme: theme)
|
|
}
|
|
|
|
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds)
|
|
self.updateNavigationBar()
|
|
|
|
self.recursivelyEnsureDisplaySynchronously(true)
|
|
|
|
if let layout = self.containerLayout {
|
|
self.containerLayoutUpdated(layout, navigationBarHeight: 0.0, transition: .immediate)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func tapActionAtPoint(_ point: CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction {
|
|
if let currentLayout = self.currentLayout {
|
|
for item in currentLayout.items {
|
|
let frame = self.effectiveFrameForItem(item)
|
|
if frame.contains(point) {
|
|
if item is InstantPagePeerReferenceItem {
|
|
return .fail
|
|
} else if item is InstantPageAudioItem {
|
|
return .fail
|
|
} else if item is InstantPageArticleItem {
|
|
return .fail
|
|
} else if item is InstantPageFeedbackItem {
|
|
return .fail
|
|
} else if let item = item as? InstantPageDetailsItem {
|
|
for (_, itemNode) in self.visibleItemsWithNodes {
|
|
if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item {
|
|
return itemNode.tapActionAtPoint(point.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY))
|
|
}
|
|
}
|
|
}
|
|
if !(item is InstantPageImageItem || item is InstantPagePlayableVideoItem) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return .waitForSingleTap
|
|
}
|
|
|
|
override func didLoad() {
|
|
super.didLoad()
|
|
|
|
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
|
|
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
|
|
}
|
|
|
|
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
|
|
recognizer.delaysTouchesBegan = false
|
|
recognizer.tapActionAtPoint = { [weak self] point in
|
|
if let strongSelf = self {
|
|
return strongSelf.tapActionAtPoint(point)
|
|
}
|
|
return .waitForSingleTap
|
|
}
|
|
recognizer.highlight = { [weak self] point in
|
|
if let strongSelf = self {
|
|
strongSelf.updateTouchesAtPoint(point)
|
|
}
|
|
}
|
|
self.scrollNode.view.addGestureRecognizer(recognizer)
|
|
}
|
|
|
|
func updateWebPage(_ webPage: TelegramMediaWebpage?, anchor: String?, state: InstantPageStoredState? = nil) {
|
|
if self.webPage?.webPage != webPage {
|
|
if self.webPage != nil && self.currentLayout != nil {
|
|
if let snaphotView = self.scrollNode.view.snapshotView(afterScreenUpdates: false) {
|
|
self.scrollNode.view.superview?.insertSubview(snaphotView, aboveSubview: self.scrollNode.view)
|
|
snaphotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snaphotView] _ in
|
|
snaphotView?.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
|
|
self.setupScrollOffsetOnLayout = self.webPage == nil
|
|
if let webPage {
|
|
var instantPage: InstantPage?
|
|
if case let .Loaded(content) = webPage.content {
|
|
instantPage = content.instantPage?._parse()
|
|
}
|
|
self.webPage = (webPage, instantPage)
|
|
} else {
|
|
self.webPage = nil
|
|
}
|
|
self.resolvedExternalMediaDimensions.removeAll()
|
|
self.pendingResolvedExternalMediaDimensions.removeAll()
|
|
if let anchor = anchor {
|
|
self.initialAnchor = anchor.removingPercentEncoding
|
|
} else if let state = state {
|
|
self.initialState = state
|
|
if !state.details.isEmpty {
|
|
var storedExpandedDetails: [Int: Bool] = [:]
|
|
for state in state.details {
|
|
storedExpandedDetails[Int(clamping: state.index)] = state.expanded
|
|
}
|
|
self.currentExpandedDetails = storedExpandedDetails
|
|
}
|
|
}
|
|
self.currentLayout = nil
|
|
self.updateLayout()
|
|
|
|
self.scrollNode.frame = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0)
|
|
if let containerLayout = self.containerLayout {
|
|
self.containerLayoutUpdated(containerLayout, navigationBarHeight: 0.0, transition: .immediate)
|
|
}
|
|
|
|
if let webPage = webPage, case let .Loaded(content) = webPage.content, let instantPage = content.instantPage, instantPage.isComplete {
|
|
self.loadProgress.set(1.0)
|
|
|
|
if let anchor = self.pendingAnchor {
|
|
self.pendingAnchor = nil
|
|
self.scrollToAnchor(anchor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
self.containerLayout = layout
|
|
|
|
if let settingsDimNode = self.settingsDimNode {
|
|
transition.updateFrame(node: settingsDimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
|
|
}
|
|
|
|
if let settingsNode = self.settingsNode {
|
|
settingsNode.updateLayout(layout: layout, transition: transition)
|
|
transition.updateFrame(node: settingsNode, frame: CGRect(origin: CGPoint(), size: layout.size))
|
|
}
|
|
|
|
let maxBarHeight: CGFloat
|
|
if !layout.safeInsets.top.isZero {
|
|
if let statusBarHeight = layout.statusBarHeight, statusBarHeight > 34.0 {
|
|
maxBarHeight = statusBarHeight + 44.0
|
|
} else {
|
|
maxBarHeight = layout.safeInsets.top + 34.0
|
|
}
|
|
} else {
|
|
maxBarHeight = (layout.statusBarHeight ?? 0.0) + 44.0
|
|
}
|
|
|
|
let scrollInsetTop = maxBarHeight
|
|
|
|
let resetOffset = self.scrollNode.bounds.size.width.isZero || self.setupScrollOffsetOnLayout || !(self.initialAnchor ?? "").isEmpty
|
|
let widthUpdated = !self.scrollNode.bounds.size.width.isEqual(to: layout.size.width)
|
|
|
|
var shouldUpdateVisibleItems = false
|
|
if self.scrollNode.bounds.size != layout.size || !self.scrollNode.view.contentInset.top.isEqual(to: scrollInsetTop) {
|
|
self.scrollNode.frame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height)
|
|
self.scrollNodeHeader.frame = CGRect(origin: CGPoint(x: 0.0, y: -2000.0), size: CGSize(width: layout.size.width, height: 2000.0))
|
|
self.scrollNode.view.contentInset = UIEdgeInsets(top: scrollInsetTop, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0)
|
|
if widthUpdated {
|
|
self.updateLayout()
|
|
}
|
|
shouldUpdateVisibleItems = true
|
|
self.updateNavigationBar()
|
|
}
|
|
var didSetScrollOffset = false
|
|
if resetOffset {
|
|
var contentOffset = CGPoint(x: 0.0, y: -self.scrollNode.view.contentInset.top)
|
|
if let state = self.initialState {
|
|
didSetScrollOffset = true
|
|
contentOffset = CGPoint(x: 0.0, y: CGFloat(state.contentOffset))
|
|
}
|
|
else if let anchor = self.initialAnchor, !anchor.isEmpty {
|
|
self.initialAnchor = nil
|
|
if let items = self.currentLayout?.items {
|
|
didSetScrollOffset = true
|
|
if let (item, lineOffset, _, _) = self.findAnchorItem(anchor, items: items) {
|
|
contentOffset = CGPoint(x: 0.0, y: item.frame.minY + lineOffset - self.scrollNode.view.contentInset.top)
|
|
}
|
|
}
|
|
} else {
|
|
didSetScrollOffset = true
|
|
}
|
|
self.scrollNode.view.contentOffset = contentOffset
|
|
if didSetScrollOffset {
|
|
self.previousContentOffset = contentOffset
|
|
self.updateNavigationBar()
|
|
if self.currentLayout != nil {
|
|
self.setupScrollOffsetOnLayout = false
|
|
}
|
|
}
|
|
}
|
|
if shouldUpdateVisibleItems {
|
|
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds)
|
|
}
|
|
}
|
|
|
|
private func updateLayout() {
|
|
guard let containerLayout = self.containerLayout, let (webPage, instantPage) = self.resolvedWebPage(), let theme = self.theme else {
|
|
return
|
|
}
|
|
|
|
let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: containerLayout.size.width, sideInset: 17.0, safeInset: containerLayout.safeInsets.left, strings: self.strings, theme: theme, dateTimeFormat: self.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights)
|
|
|
|
let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: containerLayout.size.width)
|
|
|
|
var currentDetailsItems: [InstantPageDetailsItem] = []
|
|
var currentLayoutItemsWithNodes: [InstantPageItem] = []
|
|
var distanceThresholdGroupCount: [Int : Int] = [:]
|
|
|
|
var expandedDetails: [Int : Bool] = [:]
|
|
|
|
var detailsIndex = -1
|
|
for item in currentLayout.items {
|
|
if item.wantsNode {
|
|
currentLayoutItemsWithNodes.append(item)
|
|
if let group = item.distanceThresholdGroup() {
|
|
let count: Int
|
|
if let currentCount = distanceThresholdGroupCount[Int(group)] {
|
|
count = currentCount
|
|
} else {
|
|
count = 0
|
|
}
|
|
distanceThresholdGroupCount[Int(group)] = count + 1
|
|
}
|
|
if let detailsItem = item as? InstantPageDetailsItem {
|
|
detailsIndex += 1
|
|
expandedDetails[detailsIndex] = detailsItem.initiallyExpanded
|
|
currentDetailsItems.append(detailsItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
if var currentExpandedDetails = self.currentExpandedDetails {
|
|
for (index, expanded) in expandedDetails {
|
|
if currentExpandedDetails[index] == nil {
|
|
currentExpandedDetails[index] = expanded
|
|
}
|
|
}
|
|
self.currentExpandedDetails = currentExpandedDetails
|
|
} else {
|
|
self.currentExpandedDetails = expandedDetails
|
|
}
|
|
|
|
let accessibilityAreas = instantPageAccessibilityAreasFromLayout(currentLayout, boundingWidth: containerLayout.size.width)
|
|
|
|
self.currentLayout = currentLayout
|
|
self.currentLayoutTiles = currentLayoutTiles
|
|
self.currentLayoutItemsWithNodes = currentLayoutItemsWithNodes
|
|
self.currentDetailsItems = currentDetailsItems
|
|
self.distanceThresholdGroupCount = distanceThresholdGroupCount
|
|
|
|
for areaNode in self.currentAccessibilityAreas {
|
|
areaNode.removeFromSupernode()
|
|
}
|
|
for areaNode in accessibilityAreas {
|
|
self.scrollNode.addSubnode(areaNode)
|
|
}
|
|
self.currentAccessibilityAreas = accessibilityAreas
|
|
|
|
self.scrollNode.view.contentSize = currentLayout.contentSize
|
|
self.scrollNodeFooter.frame = CGRect(origin: CGPoint(x: 0.0, y: currentLayout.contentSize.height), size: CGSize(width: containerLayout.size.width, height: 2000.0))
|
|
}
|
|
|
|
func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) {
|
|
guard let theme = self.theme else {
|
|
return
|
|
}
|
|
|
|
var visibleTileIndices = Set<Int>()
|
|
var visibleItemIndices = Set<Int>()
|
|
|
|
var topNode: ASDisplayNode?
|
|
let topTileNode = topNode
|
|
if let scrollSubnodes = self.scrollNode.subnodes {
|
|
for node in scrollSubnodes.reversed() {
|
|
if let node = node as? InstantPageTileNode {
|
|
topNode = node
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var collapseOffset: CGFloat = 0.0
|
|
let transition: ContainedViewLayoutTransition
|
|
if animated {
|
|
transition = .animated(duration: 0.3, curve: .spring)
|
|
} else {
|
|
transition = .immediate
|
|
}
|
|
|
|
var itemIndex = -1
|
|
var embedIndex = -1
|
|
var detailsIndex = -1
|
|
|
|
var previousDetailsNode: InstantPageDetailsNode?
|
|
|
|
for item in self.currentLayoutItemsWithNodes {
|
|
itemIndex += 1
|
|
if item is InstantPageWebEmbedItem {
|
|
embedIndex += 1
|
|
}
|
|
if let imageItem = item as? InstantPageImageItem, case .webpage = imageItem.media.media {
|
|
embedIndex += 1
|
|
}
|
|
if item is InstantPageDetailsItem {
|
|
detailsIndex += 1
|
|
}
|
|
|
|
var itemThreshold: CGFloat = 0.0
|
|
if let group = item.distanceThresholdGroup() {
|
|
var count: Int = 0
|
|
if let currentCount = self.distanceThresholdGroupCount[group] {
|
|
count = currentCount
|
|
}
|
|
itemThreshold = item.distanceThresholdWithGroupCount(count)
|
|
}
|
|
|
|
var itemFrame = item.frame.offsetBy(dx: 0.0, dy: -collapseOffset)
|
|
var thresholdedItemFrame = itemFrame
|
|
thresholdedItemFrame.origin.y -= itemThreshold
|
|
thresholdedItemFrame.size.height += itemThreshold * 2.0
|
|
|
|
if let detailsItem = item as? InstantPageDetailsItem, let expanded = self.currentExpandedDetails?[detailsIndex] {
|
|
let height = expanded ? self.effectiveSizeForDetails(detailsItem).height : detailsItem.titleHeight
|
|
collapseOffset += itemFrame.height - height
|
|
itemFrame = CGRect(origin: itemFrame.origin, size: CGSize(width: itemFrame.width, height: height))
|
|
}
|
|
|
|
if visibleBounds.intersects(thresholdedItemFrame) {
|
|
visibleItemIndices.insert(itemIndex)
|
|
|
|
var itemNode = self.visibleItemsWithNodes[itemIndex]
|
|
if let currentItemNode = itemNode {
|
|
if !item.matchesNode(currentItemNode) {
|
|
currentItemNode.removeFromSupernode()
|
|
self.visibleItemsWithNodes.removeValue(forKey: itemIndex)
|
|
itemNode = nil
|
|
}
|
|
}
|
|
|
|
if itemNode == nil {
|
|
let itemIndex = itemIndex
|
|
let embedIndex = embedIndex
|
|
let detailsIndex = detailsIndex
|
|
if let newNode = item.node(context: self.context, strings: self.strings, nameDisplayOrder: self.nameDisplayOrder, theme: theme, sourceLocation: self.sourceLocation, openMedia: { [weak self] media in
|
|
self?.openMedia(media)
|
|
}, longPressMedia: { [weak self] media in
|
|
self?.longPressMedia(media)
|
|
}, activatePinchPreview: { [weak self] sourceNode in
|
|
guard let strongSelf = self, let controller = strongSelf.controller else {
|
|
return
|
|
}
|
|
let pinchController = makePinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: {
|
|
guard let strongSelf = self else {
|
|
return CGRect()
|
|
}
|
|
|
|
let localRect = CGRect(origin: CGPoint(x: 0.0, y: strongSelf.navigationBar.frame.maxY), size: CGSize(width: strongSelf.bounds.width, height: strongSelf.bounds.height - strongSelf.navigationBar.frame.maxY))
|
|
return strongSelf.view.convert(localRect, to: nil)
|
|
})
|
|
controller.window?.presentInGlobalOverlay(pinchController)
|
|
}, pinchPreviewFinished: { [weak self] itemNode in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
for (_, listItemNode) in strongSelf.visibleItemsWithNodes {
|
|
if let listItemNode = listItemNode as? InstantPagePeerReferenceNode {
|
|
if listItemNode.frame.intersects(itemNode.frame) && listItemNode.frame.maxY <= itemNode.frame.maxY + 2.0 {
|
|
listItemNode.layer.animateAlpha(from: 0.0, to: listItemNode.alpha, duration: 0.25)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}, openPeer: { [weak self] peerId in
|
|
self?.openPeer(peerId)
|
|
}, openUrl: { [weak self] url in
|
|
self?.openUrl(url)
|
|
}, updateWebEmbedHeight: { [weak self] height in
|
|
self?.updateWebEmbedHeight(embedIndex, height)
|
|
}, updateDetailsExpanded: { [weak self] expanded in
|
|
self?.updateDetailsExpanded(detailsIndex, expanded)
|
|
}, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) {
|
|
newNode.frame = itemFrame
|
|
newNode.updateLayout(size: itemFrame.size, transition: transition)
|
|
if let topNode = topNode {
|
|
self.scrollNode.insertSubnode(newNode, aboveSubnode: topNode)
|
|
} else {
|
|
self.scrollNode.insertSubnode(newNode, at: 0)
|
|
}
|
|
topNode = newNode
|
|
self.visibleItemsWithNodes[itemIndex] = newNode
|
|
itemNode = newNode
|
|
self.configureExternalMediaDimensionsUpdates(for: newNode)
|
|
|
|
if let itemNode = itemNode as? InstantPageDetailsNode {
|
|
itemNode.requestLayoutUpdate = { [weak self] animated in
|
|
if let strongSelf = self {
|
|
strongSelf.updateVisibleItems(visibleBounds: strongSelf.scrollNode.view.bounds, animated: animated)
|
|
}
|
|
}
|
|
|
|
if let previousDetailsNode = previousDetailsNode {
|
|
if itemNode.frame.minY - previousDetailsNode.frame.maxY < 1.0 {
|
|
itemNode.previousNode = previousDetailsNode
|
|
}
|
|
}
|
|
previousDetailsNode = itemNode
|
|
}
|
|
}
|
|
} else {
|
|
if let itemNode = itemNode, itemNode.frame != itemFrame {
|
|
transition.updateFrame(node: itemNode, frame: itemFrame)
|
|
itemNode.updateLayout(size: itemFrame.size, transition: transition)
|
|
}
|
|
}
|
|
|
|
if let itemNode = itemNode {
|
|
self.configureExternalMediaDimensionsUpdates(for: itemNode)
|
|
}
|
|
|
|
if let itemNode = itemNode as? InstantPageDetailsNode {
|
|
itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated)
|
|
}
|
|
}
|
|
}
|
|
|
|
topNode = topTileNode
|
|
|
|
var tileIndex = -1
|
|
for tile in self.currentLayoutTiles {
|
|
tileIndex += 1
|
|
|
|
let tileFrame = effectiveFrameForTile(tile)
|
|
var tileVisibleFrame = tileFrame
|
|
tileVisibleFrame.origin.y -= 400.0
|
|
tileVisibleFrame.size.height += 400.0 * 2.0
|
|
if tileVisibleFrame.intersects(visibleBounds) {
|
|
visibleTileIndices.insert(tileIndex)
|
|
|
|
if self.visibleTiles[tileIndex] == nil {
|
|
let tileNode = InstantPageTileNode(tile: tile, backgroundColor: theme.pageBackgroundColor)
|
|
tileNode.frame = tileFrame
|
|
if let topNode = topNode {
|
|
self.scrollNode.insertSubnode(tileNode, aboveSubnode: topNode)
|
|
} else {
|
|
self.scrollNode.insertSubnode(tileNode, at: 0)
|
|
}
|
|
topNode = tileNode
|
|
self.visibleTiles[tileIndex] = tileNode
|
|
} else {
|
|
if let tileNode = self.visibleTiles[tileIndex] {
|
|
tileNode.update(tile: tile, backgroundColor: theme.pageBackgroundColor)
|
|
if tileNode.frame != tileFrame {
|
|
transition.updateFrame(node: tileNode, frame: tileFrame)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let currentLayout = self.currentLayout {
|
|
let effectiveContentHeight = currentLayout.contentSize.height - collapseOffset
|
|
if effectiveContentHeight != self.scrollNode.view.contentSize.height {
|
|
transition.animateView {
|
|
self.scrollNode.view.contentSize = CGSize(width: currentLayout.contentSize.width, height: effectiveContentHeight)
|
|
}
|
|
let previousFrame = self.scrollNodeFooter.frame
|
|
self.scrollNodeFooter.frame = CGRect(origin: CGPoint(x: 0.0, y: effectiveContentHeight), size: CGSize(width: previousFrame.width, height: 2000.0))
|
|
transition.animateFrame(node: self.scrollNodeFooter, from: previousFrame)
|
|
}
|
|
}
|
|
|
|
var removeTileIndices: [Int] = []
|
|
for (index, tileNode) in self.visibleTiles {
|
|
if !visibleTileIndices.contains(index) {
|
|
removeTileIndices.append(index)
|
|
tileNode.removeFromSupernode()
|
|
}
|
|
}
|
|
for index in removeTileIndices {
|
|
self.visibleTiles.removeValue(forKey: index)
|
|
}
|
|
|
|
var removeItemIndices: [Int] = []
|
|
for (index, itemNode) in self.visibleItemsWithNodes {
|
|
if !visibleItemIndices.contains(index) {
|
|
removeItemIndices.append(index)
|
|
itemNode.removeFromSupernode()
|
|
} else {
|
|
var itemFrame = itemNode.frame
|
|
let itemThreshold: CGFloat = 200.0
|
|
itemFrame.origin.y -= itemThreshold
|
|
itemFrame.size.height += itemThreshold * 2.0
|
|
itemNode.updateIsVisible(visibleBounds.intersects(itemFrame))
|
|
}
|
|
}
|
|
for index in removeItemIndices {
|
|
self.visibleItemsWithNodes.removeValue(forKey: index)
|
|
}
|
|
}
|
|
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds)
|
|
self.updateNavigationBar()
|
|
self.previousContentOffset = self.scrollNode.view.contentOffset
|
|
}
|
|
|
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
|
self.isDeceleratingBecauseOfDragging = decelerate
|
|
if !decelerate {
|
|
self.updateNavigationBar(forceState: true)
|
|
}
|
|
}
|
|
|
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
self.updateNavigationBar(forceState: true)
|
|
self.isDeceleratingBecauseOfDragging = false
|
|
}
|
|
|
|
func updateNavigationBar(forceState: Bool = false) {
|
|
guard let containerLayout = self.containerLayout else {
|
|
return
|
|
}
|
|
|
|
let bounds = self.scrollNode.view.bounds
|
|
let contentOffset = self.scrollNode.view.contentOffset
|
|
|
|
let maxBarHeight: CGFloat
|
|
let minBarHeight: CGFloat
|
|
if !containerLayout.safeInsets.top.isZero {
|
|
if let statusBarHeight = containerLayout.statusBarHeight, statusBarHeight > 34.0 {
|
|
maxBarHeight = statusBarHeight + 44.0
|
|
} else {
|
|
maxBarHeight = containerLayout.safeInsets.top + 34.0
|
|
}
|
|
minBarHeight = containerLayout.safeInsets.top + 8.0 + 20.0
|
|
} else {
|
|
maxBarHeight = (containerLayout.statusBarHeight ?? 0.0) + 44.0
|
|
minBarHeight = (containerLayout.statusBarHeight ?? 20.0) + 20.0
|
|
}
|
|
|
|
var pageProgress: CGFloat = 0.0
|
|
if !self.scrollNode.view.contentSize.height.isZero {
|
|
let value = (contentOffset.y + self.scrollNode.view.contentInset.top) / (self.scrollNode.view.contentSize.height - bounds.size.height + self.scrollNode.view.contentInset.top)
|
|
pageProgress = max(0.0, min(1.0, value))
|
|
}
|
|
|
|
let delta: CGFloat
|
|
if self.setupScrollOffsetOnLayout {
|
|
delta = 0.0
|
|
} else if let previousContentOffset = self.previousContentOffset {
|
|
delta = contentOffset.y - previousContentOffset.y
|
|
} else {
|
|
delta = 0.0
|
|
}
|
|
self.previousContentOffset = contentOffset
|
|
|
|
var transition: ContainedViewLayoutTransition = .immediate
|
|
var navigationBarFrame = self.navigationBar.frame
|
|
navigationBarFrame.size.width = bounds.size.width
|
|
if navigationBarFrame.size.height.isZero {
|
|
navigationBarFrame.size.height = maxBarHeight
|
|
}
|
|
|
|
if case .regular = containerLayout.metrics.widthClass {
|
|
navigationBarFrame.size.height = maxBarHeight
|
|
} else {
|
|
if forceState {
|
|
transition = .animated(duration: 0.3, curve: .spring)
|
|
|
|
let transitionFactor = (navigationBarFrame.size.height - minBarHeight) / (maxBarHeight - minBarHeight)
|
|
|
|
if contentOffset.y <= -self.scrollNode.view.contentInset.top || transitionFactor > 0.4 {
|
|
navigationBarFrame.size.height = maxBarHeight
|
|
} else {
|
|
navigationBarFrame.size.height = minBarHeight
|
|
}
|
|
} else {
|
|
if contentOffset.y <= -self.scrollNode.view.contentInset.top {
|
|
navigationBarFrame.size.height = maxBarHeight
|
|
} else {
|
|
navigationBarFrame.size.height -= delta
|
|
}
|
|
navigationBarFrame.size.height = max(minBarHeight, min(maxBarHeight, navigationBarFrame.size.height))
|
|
}
|
|
|
|
if self.setupScrollOffsetOnLayout {
|
|
navigationBarFrame.size.height = maxBarHeight
|
|
}
|
|
}
|
|
|
|
let transitionFactor = (navigationBarFrame.size.height - minBarHeight) / (maxBarHeight - minBarHeight)
|
|
|
|
if containerLayout.safeInsets.top.isZero {
|
|
let statusBarAlpha = min(1.0, max(0.0, transitionFactor))
|
|
transition.updateAlpha(node: self.statusBar, alpha: statusBarAlpha * statusBarAlpha)
|
|
self.statusBar.verticalOffset = navigationBarFrame.size.height - maxBarHeight
|
|
} else {
|
|
transition.updateAlpha(node: self.statusBar, alpha: 1.0)
|
|
self.statusBar.verticalOffset = 0.0
|
|
}
|
|
|
|
var title: String?
|
|
if let (webPage, _) = self.webPage, case let .Loaded(content) = webPage.content {
|
|
title = content.websiteName
|
|
}
|
|
|
|
transition.updateFrame(node: self.navigationBar, frame: navigationBarFrame)
|
|
self.navigationBar.updateLayout(size: navigationBarFrame.size, minHeight: minBarHeight, maxHeight: maxBarHeight, topInset: containerLayout.safeInsets.top, leftInset: containerLayout.safeInsets.left, rightInset: containerLayout.safeInsets.right, title: title, pageProgress: pageProgress, transition: transition)
|
|
|
|
transition.animateView {
|
|
self.scrollNode.view.scrollIndicatorInsets = UIEdgeInsets(top: navigationBarFrame.size.height, left: 0.0, bottom: containerLayout.intrinsicInsets.bottom, right: 0.0)
|
|
}
|
|
}
|
|
|
|
private func updateTouchesAtPoint(_ location: CGPoint?) {
|
|
var rects: [CGRect]?
|
|
if let location = location, let currentLayout = self.currentLayout {
|
|
for item in currentLayout.items {
|
|
let itemFrame = self.effectiveFrameForItem(item)
|
|
if itemFrame.contains(location) {
|
|
var contentOffset = CGPoint()
|
|
if let item = item as? InstantPageScrollableItem {
|
|
contentOffset = self.scrollableContentOffset(item: item)
|
|
}
|
|
var itemRects = item.linkSelectionRects(at: location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY))
|
|
|
|
for i in 0 ..< itemRects.count {
|
|
itemRects[i] = itemRects[i].offsetBy(dx: itemFrame.minX - contentOffset.x, dy: itemFrame.minY).insetBy(dx: -2.0, dy: -2.0)
|
|
}
|
|
if !itemRects.isEmpty {
|
|
rects = itemRects
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let rects = rects {
|
|
let linkHighlightingNode: LinkHighlightingNode
|
|
if let current = self.linkHighlightingNode {
|
|
linkHighlightingNode = current
|
|
} else {
|
|
let highlightColor = self.theme?.linkHighlightColor ?? UIColor(rgb: 0x0088ff).withAlphaComponent(0.4)
|
|
linkHighlightingNode = LinkHighlightingNode(color: highlightColor)
|
|
linkHighlightingNode.isUserInteractionEnabled = false
|
|
self.linkHighlightingNode = linkHighlightingNode
|
|
self.scrollNode.addSubnode(linkHighlightingNode)
|
|
}
|
|
linkHighlightingNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.bounds.size)
|
|
linkHighlightingNode.updateRects(rects)
|
|
} else if let linkHighlightingNode = self.linkHighlightingNode {
|
|
self.linkHighlightingNode = nil
|
|
linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in
|
|
linkHighlightingNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
|
|
private func scrollableContentOffset(item: InstantPageScrollableItem) -> CGPoint {
|
|
var contentOffset = CGPoint()
|
|
for (_, itemNode) in self.visibleItemsWithNodes {
|
|
if let itemNode = itemNode as? InstantPageScrollableNode, itemNode.item === item {
|
|
contentOffset = itemNode.contentOffset
|
|
break
|
|
}
|
|
}
|
|
return contentOffset
|
|
}
|
|
|
|
private func nodeForDetailsItem(_ item: InstantPageDetailsItem) -> InstantPageDetailsNode? {
|
|
for (_, itemNode) in self.visibleItemsWithNodes {
|
|
if let detailsNode = itemNode as? InstantPageDetailsNode, detailsNode.item === item {
|
|
return detailsNode
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func effectiveSizeForDetails(_ item: InstantPageDetailsItem) -> CGSize {
|
|
if let node = nodeForDetailsItem(item) {
|
|
return CGSize(width: item.frame.width, height: node.effectiveContentSize.height + item.titleHeight)
|
|
} else {
|
|
return item.frame.size
|
|
}
|
|
}
|
|
|
|
private func effectiveFrameForTile(_ tile: InstantPageTile) -> CGRect {
|
|
let layoutOrigin = tile.frame.origin
|
|
var origin = layoutOrigin
|
|
for item in self.currentDetailsItems {
|
|
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
|
|
if layoutOrigin.y >= item.frame.maxY {
|
|
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
|
|
origin.y += height - item.frame.height
|
|
}
|
|
}
|
|
return CGRect(origin: origin, size: tile.frame.size)
|
|
}
|
|
|
|
private func effectiveFrameForItem(_ item: InstantPageItem) -> CGRect {
|
|
let layoutOrigin = item.frame.origin
|
|
var origin = layoutOrigin
|
|
|
|
for item in self.currentDetailsItems {
|
|
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
|
|
if layoutOrigin.y >= item.frame.maxY {
|
|
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
|
|
origin.y += height - item.frame.height
|
|
}
|
|
}
|
|
|
|
if let item = item as? InstantPageDetailsItem {
|
|
let expanded = self.currentExpandedDetails?[item.index] ?? item.initiallyExpanded
|
|
let height = expanded ? self.effectiveSizeForDetails(item).height : item.titleHeight
|
|
return CGRect(origin: origin, size: CGSize(width: item.frame.width, height: height))
|
|
} else {
|
|
return CGRect(origin: origin, size: item.frame.size)
|
|
}
|
|
}
|
|
|
|
private func textItemAtLocation(_ location: CGPoint) -> (InstantPageTextItem, CGPoint)? {
|
|
if let currentLayout = self.currentLayout {
|
|
for item in currentLayout.items {
|
|
let itemFrame = self.effectiveFrameForItem(item)
|
|
if itemFrame.contains(location) {
|
|
if let item = item as? InstantPageTextItem, item.selectable {
|
|
return (item, CGPoint(x: itemFrame.minX - item.frame.minX, y: itemFrame.minY - item.frame.minY))
|
|
} else if let item = item as? InstantPageScrollableItem {
|
|
let contentOffset = scrollableContentOffset(item: item)
|
|
if let (textItem, parentOffset) = item.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) {
|
|
return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x, dy: parentOffset.y))
|
|
}
|
|
} else if let item = item as? InstantPageDetailsItem {
|
|
for (_, itemNode) in self.visibleItemsWithNodes {
|
|
if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item {
|
|
if let (textItem, parentOffset) = itemNode.textItemAtLocation(location.offsetBy(dx: -itemFrame.minX, dy: -itemFrame.minY)) {
|
|
return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func urlForTapLocation(_ location: CGPoint) -> InstantPageUrlItem? {
|
|
if let (item, parentOffset) = self.textItemAtLocation(location) {
|
|
return item.urlAttribute(at: location.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func configureExternalMediaDimensionsUpdates(for itemNode: InstantPageNode) {
|
|
let update: (MediaId, PixelDimensions) -> Void = { [weak self] mediaId, dimensions in
|
|
self?.updateExternalMediaDimensions(mediaId, dimensions)
|
|
}
|
|
if let itemNode = itemNode as? InstantPageExternalMediaDimensionsNode {
|
|
itemNode.updateExternalMediaDimensions = update
|
|
}
|
|
if let itemNode = itemNode as? InstantPageDetailsNode {
|
|
itemNode.contentNode.updateExternalMediaDimensions = update
|
|
}
|
|
}
|
|
|
|
private func updateExternalMediaDimensions(_ mediaId: MediaId, _ dimensions: PixelDimensions) {
|
|
if self.resolvedExternalMediaDimensions[mediaId] == dimensions {
|
|
return
|
|
}
|
|
self.resolvedExternalMediaDimensions[mediaId] = dimensions
|
|
self.pendingResolvedExternalMediaDimensions.insert(mediaId)
|
|
|
|
let signal: Signal<Void, NoError> = (.complete() |> delay(0.08, queue: Queue.mainQueue()))
|
|
self.updateExternalMediaDimensionsDisposable.set(signal.start(completed: { [weak self] in
|
|
self?.relayoutForResolvedExternalMediaDimensions()
|
|
}))
|
|
}
|
|
|
|
private func relayoutForResolvedExternalMediaDimensions() {
|
|
guard !self.pendingResolvedExternalMediaDimensions.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let mediaIds = Array(self.pendingResolvedExternalMediaDimensions)
|
|
self.pendingResolvedExternalMediaDimensions.removeAll()
|
|
|
|
let detailsStateMaps = self.captureExpandedDetailsStateMaps()
|
|
let viewportTop = self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentInset.top
|
|
var oldFrames: [MediaId: CGRect] = [:]
|
|
for mediaId in mediaIds {
|
|
if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) {
|
|
oldFrames[mediaId] = frame
|
|
}
|
|
}
|
|
|
|
self.updateLayout()
|
|
|
|
var newFrames: [MediaId: CGRect] = [:]
|
|
for mediaId in mediaIds {
|
|
if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) {
|
|
newFrames[mediaId] = frame
|
|
}
|
|
}
|
|
|
|
if let compensatedViewportTop = self.compensatedViewportTop(oldFrames: oldFrames, newFrames: newFrames, viewportTop: viewportTop) {
|
|
self.setViewportTop(compensatedViewportTop)
|
|
}
|
|
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds)
|
|
}
|
|
|
|
private func setViewportTop(_ viewportTop: CGFloat) {
|
|
let scrollView = self.scrollNode.view
|
|
let minOffsetY = -scrollView.contentInset.top
|
|
let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom)
|
|
let contentOffsetY = min(max(viewportTop - scrollView.contentInset.top, minOffsetY), maxOffsetY)
|
|
if contentOffsetY.isFinite {
|
|
let contentOffset = CGPoint(x: scrollView.contentOffset.x, y: contentOffsetY)
|
|
scrollView.contentOffset = contentOffset
|
|
self.previousContentOffset = contentOffset
|
|
self.updateNavigationBar()
|
|
}
|
|
}
|
|
|
|
private func compensatedViewportTop(oldFrames: [MediaId: CGRect], newFrames: [MediaId: CGRect], viewportTop: CGFloat) -> CGFloat? {
|
|
var pairedFrames: [(old: CGRect, new: CGRect)] = []
|
|
for (mediaId, oldFrame) in oldFrames {
|
|
if let newFrame = newFrames[mediaId] {
|
|
pairedFrames.append((oldFrame, newFrame))
|
|
}
|
|
}
|
|
if pairedFrames.isEmpty {
|
|
return nil
|
|
}
|
|
|
|
if let intersecting = pairedFrames
|
|
.filter({ $0.old.height > 0.0 && $0.new.height > 0.0 && viewportTop > $0.old.minY && viewportTop < $0.old.maxY })
|
|
.max(by: { $0.old.minY < $1.old.minY }) {
|
|
let ratio = min(max((viewportTop - intersecting.old.minY) / intersecting.old.height, 0.0), 1.0)
|
|
return intersecting.new.minY + ratio * intersecting.new.height
|
|
}
|
|
|
|
if let above = pairedFrames
|
|
.filter({ viewportTop >= $0.old.maxY })
|
|
.max(by: { $0.old.maxY < $1.old.maxY }) {
|
|
return viewportTop + (above.new.maxY - above.old.maxY)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func captureExpandedDetailsStateMaps() -> [String: [Int: Bool]] {
|
|
guard let currentLayout = self.currentLayout else {
|
|
return [:]
|
|
}
|
|
var result: [String: [Int: Bool]] = [:]
|
|
self.captureExpandedDetailsStateMaps(items: currentLayout.items, visibleItemsWithNodes: self.visibleItemsWithNodes, path: [], result: &result)
|
|
return result
|
|
}
|
|
|
|
private func captureExpandedDetailsStateMaps(items: [InstantPageItem], visibleItemsWithNodes: [Int: InstantPageNode], path: [Int], result: inout [String: [Int: Bool]]) {
|
|
let detailsNodes = visibleItemsWithNodes.compactMap { $0.value as? InstantPageDetailsNode }
|
|
|
|
var detailsIndex = -1
|
|
for item in items {
|
|
guard let detailsItem = item as? InstantPageDetailsItem else {
|
|
continue
|
|
}
|
|
detailsIndex += 1
|
|
|
|
guard let detailsNode = detailsNodes.first(where: { $0.item === detailsItem }) else {
|
|
continue
|
|
}
|
|
let nextPath = path + [detailsIndex]
|
|
result[self.detailsStateKey(nextPath)] = detailsNode.contentNode.currentExpandedDetails ?? [:]
|
|
self.captureExpandedDetailsStateMaps(items: detailsItem.items, visibleItemsWithNodes: detailsNode.contentNode.visibleItemsWithNodes, path: nextPath, result: &result)
|
|
}
|
|
}
|
|
|
|
private func detailsStateKey(_ path: [Int]) -> String {
|
|
if path.isEmpty {
|
|
return ""
|
|
}
|
|
return path.map(String.init).joined(separator: ".")
|
|
}
|
|
|
|
private func effectiveFrameForMedia(_ mediaId: MediaId, detailsStateMaps: [String: [Int: Bool]]) -> CGRect? {
|
|
guard let currentLayout = self.currentLayout else {
|
|
return nil
|
|
}
|
|
return self.effectiveFrameForMedia(mediaId, items: currentLayout.items, origin: .zero, expandedDetails: self.currentExpandedDetails, path: [], detailsStateMaps: detailsStateMaps)
|
|
}
|
|
|
|
private func effectiveFrameForMedia(_ mediaId: MediaId, items: [InstantPageItem], origin: CGPoint, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGRect? {
|
|
var collapseOffset: CGFloat = 0.0
|
|
var detailsIndex = -1
|
|
|
|
for item in items {
|
|
if item is InstantPageDetailsItem {
|
|
detailsIndex += 1
|
|
}
|
|
|
|
var itemFrame = item.frame.offsetBy(dx: origin.x, dy: origin.y - collapseOffset)
|
|
if let detailsItem = item as? InstantPageDetailsItem {
|
|
let nextPath = path + [detailsIndex]
|
|
let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)]
|
|
let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded
|
|
let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight
|
|
collapseOffset += item.frame.height - height
|
|
itemFrame.size.height = height
|
|
|
|
if expanded, let nestedFrame = self.effectiveFrameForMedia(mediaId, items: detailsItem.items, origin: CGPoint(x: itemFrame.minX, y: itemFrame.minY + detailsItem.titleHeight), expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) {
|
|
return nestedFrame
|
|
}
|
|
continue
|
|
}
|
|
|
|
if self.itemContainsMedia(item, mediaId: mediaId) {
|
|
return itemFrame
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func effectiveContentHeight(items: [InstantPageItem], baseHeight: CGFloat, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGFloat {
|
|
var contentHeight = baseHeight
|
|
var detailsIndex = -1
|
|
|
|
for item in items {
|
|
guard let detailsItem = item as? InstantPageDetailsItem else {
|
|
continue
|
|
}
|
|
detailsIndex += 1
|
|
|
|
let nextPath = path + [detailsIndex]
|
|
let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)]
|
|
let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded
|
|
let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight
|
|
contentHeight += -detailsItem.frame.height + height
|
|
}
|
|
|
|
return contentHeight
|
|
}
|
|
|
|
private func itemContainsMedia(_ item: InstantPageItem, mediaId: MediaId) -> Bool {
|
|
for media in item.medias {
|
|
if media.media.id == mediaId {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func resolvedWebPage() -> (webPage: TelegramMediaWebpage, instantPage: InstantPage?)? {
|
|
guard let (webPage, instantPage) = self.webPage else {
|
|
return nil
|
|
}
|
|
guard !self.resolvedExternalMediaDimensions.isEmpty, case let .Loaded(content) = webPage.content else {
|
|
return (webPage, instantPage)
|
|
}
|
|
|
|
var instantPageUpdated = false
|
|
var effectiveInstantPage = instantPage
|
|
if let instantPage {
|
|
var media = instantPage.media
|
|
for (mediaId, currentMedia) in instantPage.media {
|
|
if let updatedMedia = self.updatedMediaIfNeeded(currentMedia) {
|
|
media[mediaId] = updatedMedia
|
|
instantPageUpdated = true
|
|
}
|
|
}
|
|
if instantPageUpdated {
|
|
effectiveInstantPage = InstantPage(blocks: instantPage.blocks, media: media, isComplete: instantPage.isComplete, rtl: instantPage.rtl, url: instantPage.url, views: instantPage.views)
|
|
}
|
|
}
|
|
|
|
var imageUpdated = false
|
|
let effectiveImage = content.image.map { image -> TelegramMediaImage in
|
|
if let updated = self.updatedImageIfNeeded(image) {
|
|
imageUpdated = true
|
|
return updated
|
|
} else {
|
|
return image
|
|
}
|
|
}
|
|
|
|
var fileUpdated = false
|
|
let effectiveFile = content.file.map { file -> TelegramMediaFile in
|
|
if let updated = self.updatedFileIfNeeded(file) {
|
|
fileUpdated = true
|
|
return updated
|
|
} else {
|
|
return file
|
|
}
|
|
}
|
|
|
|
if !instantPageUpdated && !imageUpdated && !fileUpdated {
|
|
return (webPage, instantPage)
|
|
}
|
|
|
|
let effectiveContent = TelegramMediaWebpageLoadedContent(
|
|
url: content.url,
|
|
displayUrl: content.displayUrl,
|
|
hash: content.hash,
|
|
type: content.type,
|
|
websiteName: content.websiteName,
|
|
title: content.title,
|
|
text: content.text,
|
|
embedUrl: content.embedUrl,
|
|
embedType: content.embedType,
|
|
embedSize: content.embedSize,
|
|
duration: content.duration,
|
|
author: content.author,
|
|
isMediaLargeByDefault: content.isMediaLargeByDefault,
|
|
imageIsVideoCover: content.imageIsVideoCover,
|
|
image: effectiveImage,
|
|
file: effectiveFile,
|
|
story: content.story,
|
|
attributes: content.attributes,
|
|
instantPage: effectiveInstantPage
|
|
)
|
|
return (TelegramMediaWebpage(webpageId: webPage.webpageId, content: .Loaded(effectiveContent)), effectiveInstantPage)
|
|
}
|
|
|
|
private func updatedMediaIfNeeded(_ media: Media) -> Media? {
|
|
if let image = media as? TelegramMediaImage {
|
|
return self.updatedImageIfNeeded(image)
|
|
} else if let file = media as? TelegramMediaFile {
|
|
return self.updatedFileIfNeeded(file)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func updatedImageIfNeeded(_ image: TelegramMediaImage) -> TelegramMediaImage? {
|
|
guard let dimensions = self.resolvedExternalMediaDimensions[image.imageId] else {
|
|
return nil
|
|
}
|
|
|
|
var updatedRepresentations = image.representations
|
|
var didUpdate = false
|
|
for i in 0 ..< updatedRepresentations.count {
|
|
let representation = updatedRepresentations[i]
|
|
guard representation.resource is InstantPageExternalMediaResource, representation.dimensions != dimensions else {
|
|
continue
|
|
}
|
|
updatedRepresentations[i] = TelegramMediaImageRepresentation(
|
|
dimensions: dimensions,
|
|
resource: representation.resource,
|
|
progressiveSizes: representation.progressiveSizes,
|
|
immediateThumbnailData: representation.immediateThumbnailData,
|
|
hasVideo: representation.hasVideo,
|
|
isPersonal: representation.isPersonal,
|
|
typeHint: representation.typeHint
|
|
)
|
|
didUpdate = true
|
|
}
|
|
|
|
guard didUpdate else {
|
|
return nil
|
|
}
|
|
return TelegramMediaImage(
|
|
imageId: image.imageId,
|
|
representations: updatedRepresentations,
|
|
videoRepresentations: image.videoRepresentations,
|
|
immediateThumbnailData: image.immediateThumbnailData,
|
|
emojiMarkup: image.emojiMarkup,
|
|
reference: image.reference,
|
|
partialReference: image.partialReference,
|
|
flags: image.flags,
|
|
video: image.video
|
|
)
|
|
}
|
|
|
|
private func updatedFileIfNeeded(_ file: TelegramMediaFile) -> TelegramMediaFile? {
|
|
guard let dimensions = self.resolvedExternalMediaDimensions[file.fileId], file.resource is InstantPageExternalMediaResource else {
|
|
return nil
|
|
}
|
|
|
|
let (attributes, didUpdate) = self.fileAttributesWithResolvedDimensions(file.attributes, dimensions: dimensions)
|
|
guard didUpdate else {
|
|
return nil
|
|
}
|
|
|
|
return TelegramMediaFile(
|
|
fileId: file.fileId,
|
|
partialReference: file.partialReference,
|
|
resource: file.resource,
|
|
previewRepresentations: file.previewRepresentations,
|
|
videoThumbnails: file.videoThumbnails,
|
|
videoCover: file.videoCover,
|
|
immediateThumbnailData: file.immediateThumbnailData,
|
|
mimeType: file.mimeType,
|
|
size: file.size,
|
|
attributes: attributes,
|
|
alternativeRepresentations: file.alternativeRepresentations
|
|
)
|
|
}
|
|
|
|
private func fileAttributesWithResolvedDimensions(_ attributes: [TelegramMediaFileAttribute], dimensions: PixelDimensions) -> ([TelegramMediaFileAttribute], Bool) {
|
|
var updatedAttributes: [TelegramMediaFileAttribute] = []
|
|
var didUpdate = false
|
|
var hasSizeAttribute = false
|
|
|
|
for attribute in attributes {
|
|
switch attribute {
|
|
case let .ImageSize(size):
|
|
hasSizeAttribute = true
|
|
if size != dimensions {
|
|
updatedAttributes.append(.ImageSize(size: dimensions))
|
|
didUpdate = true
|
|
} else {
|
|
updatedAttributes.append(attribute)
|
|
}
|
|
case let .Video(duration, size, flags, preloadSize, coverTime, videoCodec):
|
|
hasSizeAttribute = true
|
|
if size != dimensions {
|
|
updatedAttributes.append(.Video(duration: duration, size: dimensions, flags: flags, preloadSize: preloadSize, coverTime: coverTime, videoCodec: videoCodec))
|
|
didUpdate = true
|
|
} else {
|
|
updatedAttributes.append(attribute)
|
|
}
|
|
default:
|
|
updatedAttributes.append(attribute)
|
|
}
|
|
}
|
|
|
|
if !hasSizeAttribute {
|
|
updatedAttributes.append(.ImageSize(size: dimensions))
|
|
didUpdate = true
|
|
}
|
|
|
|
return (updatedAttributes, didUpdate)
|
|
}
|
|
|
|
private func longPressMedia(_ media: InstantPageMedia) {
|
|
let controller = makeContextMenuController(actions: [ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in
|
|
if let strongSelf = self, case let .image(image) = media.media {
|
|
let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
|
|
let _ = copyToPasteboard(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
|
}
|
|
}), ContextMenuAction(content: .text(title: self.strings.Conversation_LinkDialogSave, accessibilityLabel: self.strings.Conversation_LinkDialogSave), action: { [weak self] in
|
|
if let strongSelf = self, case let .image(image) = media.media {
|
|
let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: [])
|
|
let _ = saveToCameraRoll(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start()
|
|
}
|
|
}), ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in
|
|
if let strongSelf = self, let (webPage, _) = strongSelf.webPage, case let .image(image) = media.media {
|
|
strongSelf.present(strongSelf.context.sharedContext.makeShareController(context: strongSelf.context, params: ShareControllerParams(subject: .image(image.representations.map({ ImageRepresentationWithReference(representation: $0, reference: MediaResourceReference.media(media: .webPage(webPage: WebpageReference(webPage), media: image), resource: $0.resource)) })))), nil)
|
|
}
|
|
})], catchTapsOutside: true)
|
|
self.present(controller, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
|
|
if let strongSelf = self {
|
|
for (_, itemNode) in strongSelf.visibleItemsWithNodes {
|
|
if let (node, _, _) = itemNode.transitionNode(media: media) {
|
|
return (strongSelf.scrollNode, node.convert(node.bounds, to: strongSelf.scrollNode), strongSelf, strongSelf.bounds)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}))
|
|
}
|
|
|
|
@objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
|
|
switch recognizer.state {
|
|
case .ended:
|
|
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
|
|
switch gesture {
|
|
case .tap:
|
|
if let url = self.urlForTapLocation(location) {
|
|
self.openUrl(url)
|
|
}
|
|
case .longTap:
|
|
if let theme = self.theme, let url = self.urlForTapLocation(location) {
|
|
let canOpenIn = availableOpenInOptions(context: self.context, item: .url(url: url.url)).count > 1
|
|
let openText = canOpenIn ? self.strings.Conversation_FileOpenIn : self.strings.Conversation_LinkDialogOpen
|
|
let actionSheet = ActionSheetController(instantPageTheme: theme)
|
|
actionSheet.setItemGroups([ActionSheetItemGroup(items: [
|
|
ActionSheetTextItem(title: url.url),
|
|
ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in
|
|
actionSheet?.dismissAnimated()
|
|
if let strongSelf = self {
|
|
if canOpenIn {
|
|
strongSelf.openUrlIn(url)
|
|
} else {
|
|
strongSelf.openUrl(url)
|
|
}
|
|
}
|
|
}),
|
|
ActionSheetButtonItem(title: self.strings.ShareMenu_CopyShareLink, color: .accent, action: { [weak actionSheet] in
|
|
actionSheet?.dismissAnimated()
|
|
UIPasteboard.general.string = url.url
|
|
}),
|
|
ActionSheetButtonItem(title: self.strings.Conversation_AddToReadingList, color: .accent, action: { [weak actionSheet] in
|
|
actionSheet?.dismissAnimated()
|
|
if let link = URL(string: url.url) {
|
|
let _ = try? SSReadingList.default()?.addItem(with: link, title: nil, previewText: nil)
|
|
}
|
|
})
|
|
]), ActionSheetItemGroup(items: [
|
|
ActionSheetButtonItem(title: self.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
|
|
actionSheet?.dismissAnimated()
|
|
})
|
|
])])
|
|
self.present(actionSheet, nil)
|
|
} else if let (item, parentOffset) = self.textItemAtLocation(location) {
|
|
let textFrame = item.frame
|
|
var itemRects = item.lineRects()
|
|
for i in 0 ..< itemRects.count {
|
|
itemRects[i] = itemRects[i].offsetBy(dx: parentOffset.x + textFrame.minX, dy: parentOffset.y + textFrame.minY).insetBy(dx: -2.0, dy: -2.0)
|
|
}
|
|
self.updateTextSelectionRects(itemRects, text: item.plainText())
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func updateTextSelectionRects(_ rects: [CGRect], text: String?) {
|
|
if let text = text, !rects.isEmpty {
|
|
let textSelectionNode: LinkHighlightingNode
|
|
if let current = self.textSelectionNode {
|
|
textSelectionNode = current
|
|
} else {
|
|
textSelectionNode = LinkHighlightingNode(color: UIColor.lightGray.withAlphaComponent(0.4))
|
|
textSelectionNode.isUserInteractionEnabled = false
|
|
self.textSelectionNode = textSelectionNode
|
|
self.scrollNode.addSubnode(textSelectionNode)
|
|
}
|
|
textSelectionNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.bounds.size)
|
|
textSelectionNode.updateRects(rects)
|
|
|
|
var coveringRect = rects[0]
|
|
for i in 1 ..< rects.count {
|
|
coveringRect = coveringRect.union(rects[i])
|
|
}
|
|
|
|
let context = self.context
|
|
let strings = self.strings
|
|
let _ = (context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings])
|
|
|> take(1)
|
|
|> deliverOnMainQueue).start(next: { [weak self] sharedData in
|
|
let translationSettings: TranslationSettings
|
|
if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.translationSettings]?.get(TranslationSettings.self) {
|
|
translationSettings = current
|
|
} else {
|
|
translationSettings = TranslationSettings.defaultSettings
|
|
}
|
|
|
|
var actions: [ContextMenuAction] = [ContextMenuAction(content: .text(title: strings.Conversation_ContextMenuCopy, accessibilityLabel: strings.Conversation_ContextMenuCopy), action: { [weak self] in
|
|
UIPasteboard.general.string = text
|
|
|
|
if let strongSelf = self {
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .copy(text: strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil)
|
|
}
|
|
}), ContextMenuAction(content: .text(title: strings.Conversation_ContextMenuShare, accessibilityLabel: strings.Conversation_ContextMenuShare), action: { [weak self] in
|
|
if let strongSelf = self, let (webPage, _) = strongSelf.webPage, case let .Loaded(content) = webPage.content {
|
|
strongSelf.present(strongSelf.context.sharedContext.makeShareController(context: strongSelf.context, params: ShareControllerParams(subject: .quote(text: text, url: content.url))), nil)
|
|
}
|
|
})]
|
|
|
|
let (canTranslate, language) = canTranslateText(context: context, text: text, showTranslate: translationSettings.showTranslate, showTranslateIfTopical: false, ignoredLanguages: translationSettings.ignoredLanguages)
|
|
if canTranslate {
|
|
actions.append(ContextMenuAction(content: .text(title: strings.Conversation_ContextMenuTranslate, accessibilityLabel: strings.Conversation_ContextMenuTranslate), action: { [weak self] in
|
|
let controller = TranslateScreen(context: context, text: text, canCopy: true, fromLanguage: language, ignoredLanguages: translationSettings.ignoredLanguages)
|
|
controller.pushController = { [weak self] c in
|
|
(self?.controller?.navigationController as? NavigationController)?._keepModalDismissProgress = true
|
|
self?.controller?.push(c)
|
|
}
|
|
controller.presentController = { [weak self] c in
|
|
self?.controller?.present(c, in: .window(.root))
|
|
}
|
|
self?.present(controller, nil)
|
|
}))
|
|
}
|
|
|
|
let controller = makeContextMenuController(actions: actions)
|
|
controller.dismissed = { [weak self] in
|
|
self?.updateTextSelectionRects([], text: nil)
|
|
}
|
|
self?.present(controller, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
|
|
if let strongSelf = self {
|
|
return (strongSelf.scrollNode, coveringRect.insetBy(dx: -3.0, dy: -3.0), strongSelf, strongSelf.bounds)
|
|
} else {
|
|
return nil
|
|
}
|
|
}))
|
|
})
|
|
|
|
textSelectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18)
|
|
} else if let textSelectionNode = self.textSelectionNode {
|
|
self.textSelectionNode = nil
|
|
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
|
|
textSelectionNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
|
|
private func findAnchorItem(_ anchor: String, items: [InstantPageItem]) -> (InstantPageItem, CGFloat, Bool, [InstantPageDetailsItem])? {
|
|
for item in items {
|
|
if let item = item as? InstantPageAnchorItem, item.anchor == anchor {
|
|
return (item, -10.0, false, [])
|
|
} else if let item = item as? InstantPageTextItem {
|
|
if let (lineIndex, empty) = item.anchors[anchor] {
|
|
return (item, item.lines[lineIndex].frame.minY - 10.0, !empty, [])
|
|
}
|
|
}
|
|
else if let item = item as? InstantPageTableItem {
|
|
if let (offset, empty) = item.anchors[anchor] {
|
|
return (item, offset - 10.0, !empty, [])
|
|
}
|
|
}
|
|
else if let item = item as? InstantPageDetailsItem {
|
|
if let (foundItem, offset, reference, detailsItems) = self.findAnchorItem(anchor, items: item.items) {
|
|
var detailsItems = detailsItems
|
|
detailsItems.insert(item, at: 0)
|
|
return (foundItem, offset, reference, detailsItems)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func presentReferenceView(item: InstantPageTextItem, referenceAnchor: String) {
|
|
guard let theme = self.theme, let (webPage, instantPage) = self.webPage else {
|
|
return
|
|
}
|
|
|
|
var targetAnchor: InstantPageTextAnchorItem?
|
|
for (name, (line, _)) in item.anchors {
|
|
if name == referenceAnchor {
|
|
let anchors = item.lines[line].anchorItems
|
|
for anchor in anchors {
|
|
if anchor.name == referenceAnchor {
|
|
targetAnchor = anchor
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let anchorText = targetAnchor?.anchorText else {
|
|
return
|
|
}
|
|
|
|
let controller = InstantPageReferenceController(context: self.context, sourceLocation: self.sourceLocation, theme: theme, webPage: webPage, instantPage: instantPage, anchorText: anchorText, openUrl: { [weak self] url in
|
|
self?.openUrl(url)
|
|
}, openUrlIn: { [weak self] url in
|
|
self?.openUrlIn(url)
|
|
}, present: { [weak self] c, a in
|
|
self?.present(c, a)
|
|
})
|
|
self.present(controller, nil)
|
|
}
|
|
|
|
private func scrollToAnchor(_ anchor: String) {
|
|
guard let items = self.currentLayout?.items else {
|
|
return
|
|
}
|
|
|
|
if !anchor.isEmpty {
|
|
if let (item, lineOffset, reference, detailsItems) = findAnchorItem(String(anchor), items: items) {
|
|
if let item = item as? InstantPageTextItem, reference {
|
|
self.presentReferenceView(item: item, referenceAnchor: anchor)
|
|
} else {
|
|
var previousDetailsNode: InstantPageDetailsNode?
|
|
var containerOffset: CGFloat = 0.0
|
|
for detailsItem in detailsItems {
|
|
if let previousNode = previousDetailsNode {
|
|
previousNode.contentNode.updateDetailsExpanded(detailsItem.index, true, animated: false)
|
|
let frame = previousNode.effectiveFrameForItem(detailsItem)
|
|
containerOffset += frame.minY
|
|
|
|
previousDetailsNode = previousNode.contentNode.nodeForDetailsItem(detailsItem)
|
|
previousDetailsNode?.setExpanded(true, animated: false)
|
|
} else {
|
|
self.updateDetailsExpanded(detailsItem.index, true, animated: false)
|
|
let frame = self.effectiveFrameForItem(detailsItem)
|
|
containerOffset += frame.minY
|
|
|
|
previousDetailsNode = self.nodeForDetailsItem(detailsItem)
|
|
previousDetailsNode?.setExpanded(true, animated: false)
|
|
}
|
|
}
|
|
|
|
let frame: CGRect
|
|
if let previousDetailsNode = previousDetailsNode {
|
|
frame = previousDetailsNode.effectiveFrameForItem(item)
|
|
} else {
|
|
frame = self.effectiveFrameForItem(item)
|
|
}
|
|
|
|
var targetY = min(containerOffset + frame.minY + lineOffset, self.scrollNode.view.contentSize.height - self.scrollNode.frame.height)
|
|
if targetY < self.scrollNode.view.contentOffset.y {
|
|
targetY -= self.scrollNode.view.contentInset.top
|
|
} else {
|
|
targetY -= self.containerLayout?.statusBarHeight ?? 20.0
|
|
}
|
|
self.scrollNode.view.setContentOffset(CGPoint(x: 0.0, y: targetY), animated: true)
|
|
}
|
|
} else if let (_, instantPage) = self.webPage, let instantPage, !instantPage.isComplete {
|
|
self.loadProgress.set(0.5)
|
|
self.pendingAnchor = anchor
|
|
}
|
|
} else {
|
|
self.scrollNode.view.setContentOffset(CGPoint(x: 0.0, y: -self.scrollNode.view.contentInset.top), animated: true)
|
|
}
|
|
}
|
|
|
|
private func openUrl(_ url: InstantPageUrlItem) {
|
|
var baseUrl = url.url
|
|
var anchor: String?
|
|
if let anchorRange = url.url.range(of: "#") {
|
|
anchor = String(baseUrl[anchorRange.upperBound...]).removingPercentEncoding
|
|
baseUrl = String(baseUrl[..<anchorRange.lowerBound])
|
|
}
|
|
|
|
if let (_, page) = self.webPage, let page, page.url == baseUrl, let anchor = anchor {
|
|
self.scrollToAnchor(anchor)
|
|
return
|
|
}
|
|
|
|
self.loadProgress.set(0.0)
|
|
self.loadProgress.set(0.02)
|
|
|
|
self.loadWebpageDisposable.set(nil)
|
|
self.resolveUrlDisposable.set((self.context.sharedContext.resolveUrl(context: self.context, peerId: nil, url: url.url, skipUrlAuth: true)
|
|
|> deliverOnMainQueue).start(next: { [weak self] result in
|
|
if let strongSelf = self {
|
|
strongSelf.loadProgress.set(0.07)
|
|
switch result {
|
|
case let .externalUrl(externalUrl):
|
|
if let webpageId = url.webpageId {
|
|
var anchor: String?
|
|
if let anchorRange = externalUrl.range(of: "#") {
|
|
anchor = String(externalUrl[anchorRange.upperBound...])
|
|
}
|
|
strongSelf.loadWebpageDisposable.set((webpagePreviewWithProgress(account: strongSelf.context.account, urls: [externalUrl], webpageId: webpageId)
|
|
|> deliverOnMainQueue).start(next: { result in
|
|
if let strongSelf = self {
|
|
switch result {
|
|
case let .result(webpageResult):
|
|
if let webpageResult = webpageResult, case .Loaded = webpageResult.webpage.content {
|
|
strongSelf.loadProgress.set(1.0)
|
|
strongSelf.pushController(InstantPageController(context: strongSelf.context, webPage: webpageResult.webpage, sourceLocation: strongSelf.sourceLocation, anchor: anchor))
|
|
}
|
|
break
|
|
case let .progress(progress):
|
|
strongSelf.loadProgress.set(CGFloat(0.07 + progress * (1.0 - 0.07)))
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
strongSelf.loadProgress.set(1.0)
|
|
strongSelf.context.sharedContext.openExternalUrl(context: strongSelf.context, urlContext: .generic, url: externalUrl, forceExternal: false, presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, navigationController: strongSelf.getNavigationController(), dismissInput: {
|
|
self?.view.endEditing(true)
|
|
})
|
|
}
|
|
default:
|
|
strongSelf.loadProgress.set(1.0)
|
|
strongSelf.context.sharedContext.openResolvedUrl(result, context: strongSelf.context, urlContext: .generic, navigationController: strongSelf.getNavigationController(), forceExternal: false, forceUpdate: false, openPeer: { peer, navigation in
|
|
switch navigation {
|
|
case let .chat(_, subject, peekData):
|
|
if let navigationController = strongSelf.getNavigationController() {
|
|
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), subject: subject, peekData: peekData))
|
|
}
|
|
case let .withBotStartPayload(botStart):
|
|
if let navigationController = strongSelf.getNavigationController() {
|
|
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), botStart: botStart, keepStack: .always))
|
|
}
|
|
case let .withAttachBot(attachBotStart):
|
|
if let navigationController = strongSelf.getNavigationController() {
|
|
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), attachBotStart: attachBotStart))
|
|
}
|
|
case let .withBotApp(botAppStart):
|
|
if let navigationController = strongSelf.getNavigationController() {
|
|
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), botAppStart: botAppStart))
|
|
}
|
|
case .info:
|
|
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peer.id))
|
|
|> deliverOnMainQueue).start(next: { peer in
|
|
if let strongSelf = self, let peer = peer {
|
|
if let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {
|
|
strongSelf.getNavigationController()?.pushViewController(controller)
|
|
}
|
|
}
|
|
})
|
|
default:
|
|
break
|
|
}
|
|
},
|
|
sendFile: nil,
|
|
sendSticker: nil,
|
|
sendEmoji: nil,
|
|
requestMessageActionUrlAuth: nil,
|
|
joinVoiceChat: nil,
|
|
present: { c, a in
|
|
self?.present(c, a)
|
|
}, dismissInput: {
|
|
self?.view.endEditing(true)
|
|
}, contentContext: nil, progress: nil, completion: nil)
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
|
|
private func openUrlIn(_ url: InstantPageUrlItem) {
|
|
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
|
let actionSheet = OpenInActionSheetController(context: self.context, item: .url(url: url.url), openUrl: { [weak self] url in
|
|
if let strongSelf = self, let navigationController = strongSelf.getNavigationController() {
|
|
strongSelf.context.sharedContext.openExternalUrl(context: strongSelf.context, urlContext: .generic, url: url, forceExternal: true, presentationData: presentationData, navigationController: navigationController, dismissInput: {})
|
|
}
|
|
})
|
|
self.present(actionSheet, nil)
|
|
}
|
|
|
|
private func mediasFromItems(_ items: [InstantPageItem]) -> [InstantPageMedia] {
|
|
var medias: [InstantPageMedia] = []
|
|
for item in items {
|
|
if let detailsItem = item as? InstantPageDetailsItem {
|
|
medias.append(contentsOf: mediasFromItems(detailsItem.items))
|
|
} else {
|
|
if let item = item as? InstantPageImageItem, item.interactive {
|
|
medias.append(contentsOf: item.medias)
|
|
} else if let item = item as? InstantPagePlayableVideoItem, item.interactive {
|
|
medias.append(contentsOf: item.medias)
|
|
}
|
|
}
|
|
}
|
|
return medias
|
|
}
|
|
|
|
private func openMedia(_ media: InstantPageMedia) {
|
|
guard let items = self.currentLayout?.items, let (webPage, _) = self.webPage else {
|
|
return
|
|
}
|
|
|
|
if case let .file(file) = media.media, (file.isVoice || file.isMusic) {
|
|
var medias: [InstantPageMedia] = []
|
|
var initialIndex = 0
|
|
for item in items {
|
|
for itemMedia in item.medias {
|
|
if case let .file(itemFile) = itemMedia.media, (itemFile.isVoice || itemFile.isMusic) {
|
|
if itemMedia.index == media.index {
|
|
initialIndex = medias.count
|
|
}
|
|
medias.append(itemMedia)
|
|
}
|
|
}
|
|
}
|
|
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
|
return
|
|
}
|
|
|
|
openInstantPageMedia(
|
|
media: media,
|
|
allMedias: self.mediasFromItems(items),
|
|
webPage: webPage,
|
|
context: self.context,
|
|
userLocation: self.sourceLocation.userLocation,
|
|
present: { [weak self] controller, args in
|
|
self?.present(controller, args)
|
|
},
|
|
push: { [weak self] controller in
|
|
self?.pushController(controller)
|
|
},
|
|
openUrl: { [weak self] url in
|
|
self?.openUrl(url)
|
|
},
|
|
baseNavigationController: { [weak self] in
|
|
self?.getNavigationController()
|
|
},
|
|
transitionArgsForMedia: { [weak self] tappedMedia -> GalleryTransitionArguments? in
|
|
guard let strongSelf = self else { return nil }
|
|
for (_, itemNode) in strongSelf.visibleItemsWithNodes {
|
|
if let transitionNode = itemNode.transitionNode(media: tappedMedia) {
|
|
return GalleryTransitionArguments(transitionNode: transitionNode, addToTransitionSurface: { view in
|
|
if let strongSelf = self {
|
|
strongSelf.scrollNode.view.superview?.insertSubview(view, aboveSubview: strongSelf.scrollNode.view)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
hiddenMediaCallback: { [weak self] hidden in
|
|
guard let strongSelf = self else { return }
|
|
for (_, itemNode) in strongSelf.visibleItemsWithNodes {
|
|
itemNode.updateHiddenMedia(media: hidden)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func updateWebEmbedHeight(_ index: Int, _ height: CGFloat) {
|
|
let currentHeight = self.currentWebEmbedHeights[index]
|
|
if height != currentHeight {
|
|
if let currentHeight = currentHeight, currentHeight > height {
|
|
return
|
|
}
|
|
self.currentWebEmbedHeights[index] = height
|
|
|
|
let signal: Signal<Void, NoError> = (.complete() |> delay(0.08, queue: Queue.mainQueue()))
|
|
self.updateLayoutDisposable.set(signal.start(completed: { [weak self] in
|
|
if let strongSelf = self {
|
|
strongSelf.updateLayout()
|
|
strongSelf.updateVisibleItems(visibleBounds: strongSelf.scrollNode.view.bounds)
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
|
|
private func updateDetailsExpanded(_ index: Int, _ expanded: Bool, animated: Bool = true) {
|
|
if var currentExpandedDetails = self.currentExpandedDetails {
|
|
currentExpandedDetails[index] = expanded
|
|
self.currentExpandedDetails = currentExpandedDetails
|
|
}
|
|
self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds, animated: animated)
|
|
}
|
|
|
|
private func presentSettings() {
|
|
guard let settings = self.settings, let containerLayout = self.containerLayout else {
|
|
return
|
|
}
|
|
if self.settingsNode == nil {
|
|
let settingsNode = InstantPageSettingsNode(strings: self.strings, settings: settings, currentThemeType: instantPageThemeTypeForSettingsAndTime(themeSettings: self.themeSettings, settings: settings, time: self.themeReferenceDate, forceDarkTheme: self.autoNightModeTriggered), applySettings: { [weak self] settings in
|
|
if let strongSelf = self {
|
|
strongSelf.update(settings: settings, themeSettings: strongSelf.themeSettings, strings: strongSelf.strings)
|
|
let _ = updateInstantPagePresentationSettingsInteractively(accountManager: strongSelf.context.sharedContext.accountManager, { _ in
|
|
return settings
|
|
}).start()
|
|
}
|
|
}, openInSafari: { [weak self] in
|
|
if let strongSelf = self, let (webPage, _) = strongSelf.webPage, case let .Loaded(content) = webPage.content {
|
|
strongSelf.context.sharedContext.applicationBindings.openUrl(content.url)
|
|
}
|
|
})
|
|
self.addSubnode(settingsNode)
|
|
self.settingsNode = settingsNode
|
|
|
|
let settingsDimNode = ASDisplayNode()
|
|
settingsDimNode.backgroundColor = UIColor(rgb: 0, alpha: 0.1)
|
|
settingsDimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(settingsDimTapped(_:))))
|
|
self.insertSubnode(settingsDimNode, belowSubnode: self.navigationBar)
|
|
self.settingsDimNode = settingsDimNode
|
|
|
|
settingsDimNode.frame = CGRect(origin: CGPoint(), size: containerLayout.size)
|
|
|
|
settingsNode.frame = CGRect(origin: CGPoint(), size: containerLayout.size)
|
|
settingsNode.updateLayout(layout: containerLayout, transition: .immediate)
|
|
settingsNode.animateIn()
|
|
settingsDimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
|
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut)
|
|
self.navigationBar.updateDimmed(true, transition: transition)
|
|
transition.updateAlpha(node: self.statusBar, alpha: 0.5)
|
|
}
|
|
}
|
|
|
|
@objc func settingsDimTapped(_ recognizer: UITapGestureRecognizer) {
|
|
if case .ended = recognizer.state {
|
|
if let settingsNode = self.settingsNode {
|
|
self.settingsNode = nil
|
|
settingsNode.animateOut(completion: { [weak settingsNode] in
|
|
settingsNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
|
|
if let settingsDimNode = self.settingsDimNode {
|
|
self.settingsDimNode = nil
|
|
settingsDimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak settingsDimNode] _ in
|
|
settingsDimNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
|
|
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
|
|
self.navigationBar.updateDimmed(false, transition: transition)
|
|
transition.updateAlpha(node: self.statusBar, alpha: 1.0)
|
|
}
|
|
}
|
|
}
|