import Foundation import UIKit import SwiftSignalKit import AsyncDisplayKit import Display import TelegramCore import TelegramPresentationData import ActivityIndicator import AppBundle import AvatarNode import AccountContext import ComponentFlow import EmojiStatusComponent import ComponentDisplayAdapters private func generateLoupeIcon(color: UIColor) -> UIImage? { return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Loupe"), color: color) } private func generateHashtagIcon(color: UIColor) -> UIImage? { return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Hashtag"), color: color) } private func generateCashtagIcon(color: UIColor) -> UIImage? { return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Cashtag"), color: color) } private func generateClearIcon(color: UIColor) -> UIImage? { return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: color) } private func generateBackground(foregroundColor: UIColor, diameter: CGFloat) -> UIImage? { return generateImage(CGSize(width: diameter, height: diameter), contextGenerator: { size, context in context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) context.fill(CGRect(origin: CGPoint(), size: size)) context.setBlendMode(.normal) context.setFillColor(foregroundColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) }, opaque: false)?.stretchableImage(withLeftCapWidth: Int(diameter / 2.0), topCapHeight: Int(diameter / 2.0)) } public struct SearchBarToken { public struct Style { public let backgroundColor: UIColor public let foregroundColor: UIColor public let strokeColor: UIColor public init(backgroundColor: UIColor, foregroundColor: UIColor, strokeColor: UIColor) { self.backgroundColor = backgroundColor self.foregroundColor = foregroundColor self.strokeColor = strokeColor } } public let id: AnyHashable public let context: AccountContext? public let icon: UIImage? public let iconOffset: CGFloat? public let peer: (EnginePeer, AccountContext, PresentationTheme)? public let isTag: Bool public let reaction: MessageReaction.Reaction? public let emojiFile: TelegramMediaFile? public let title: String public let style: Style? public let permanent: Bool public init(id: AnyHashable, context: AccountContext? = nil, icon: UIImage?, iconOffset: CGFloat? = 0.0, peer: (EnginePeer, AccountContext, PresentationTheme)? = nil, isTag: Bool = false, reaction: MessageReaction.Reaction? = nil, emojiFile: TelegramMediaFile? = nil, title: String, style: Style? = nil, permanent: Bool) { self.id = id self.context = context self.icon = icon self.iconOffset = iconOffset self.peer = peer self.isTag = isTag self.emojiFile = emojiFile self.reaction = reaction self.title = title self.style = style self.permanent = permanent } } private final class TokenNode: ASDisplayNode { var theme: SearchBarNodeTheme let token: SearchBarToken let containerNode: ASDisplayNode let iconNode: ASImageNode let titleNode: ASTextNode let backgroundNode: ASImageNode let avatarNode: AvatarNode? var emojiView: ComponentView? var isSelected: Bool = false var isCollapsed: Bool = false var tapped: (() -> Void)? init(theme: SearchBarNodeTheme, token: SearchBarToken) { self.theme = theme self.token = token self.containerNode = ASDisplayNode() self.containerNode.clipsToBounds = true self.iconNode = ASImageNode() self.iconNode.displaysAsynchronously = false self.iconNode.displayWithoutProcessing = true self.titleNode = ASTextNode() self.titleNode.isUserInteractionEnabled = false self.titleNode.displaysAsynchronously = false self.titleNode.maximumNumberOfLines = 1 self.backgroundNode = ASImageNode() self.backgroundNode.displaysAsynchronously = false self.backgroundNode.displayWithoutProcessing = true if let _ = token.peer { self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0)) } else { self.avatarNode = nil } super.init() self.clipsToBounds = true self.addSubnode(self.containerNode) if let avatarNode = self.avatarNode, let (peer, context, theme) = token.peer { avatarNode.setPeer(context: context, theme: theme, peer: peer, clipStyle: .roundedRect, displayDimensions: CGSize(width: 24.0, height: 24.0)) self.containerNode.addSubnode(avatarNode) } else { self.containerNode.addSubnode(self.backgroundNode) let backgroundColor = token.isTag ? theme.inputIcon.withMultipliedAlpha(0.2) : (token.style?.backgroundColor ?? theme.inputIcon) let strokeColor = token.style?.strokeColor ?? backgroundColor if token.isTag { self.backgroundNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/SearchTagTokenBackground"), color: backgroundColor)?.stretchableImage(withLeftCapWidth: 7, topCapHeight: 0) } else { self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 8.0, color: backgroundColor, strokeColor: strokeColor, strokeWidth: UIScreenPixel, backgroundColor: nil) } let foregroundColor = token.isTag ? theme.primaryText : (token.style?.foregroundColor ?? .white) self.iconNode.image = generateTintedImage(image: token.icon, color: foregroundColor) self.containerNode.addSubnode(self.iconNode) self.titleNode.attributedText = NSAttributedString(string: token.title, font: Font.regular(token.isTag ? 14.0 : 17.0), textColor: foregroundColor) self.containerNode.addSubnode(self.titleNode) } } override func didLoad() { super.didLoad() self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture))) } @objc private func tapGesture() { self.tapped?() } func animateIn() { if self.token.isTag { self.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } else { let targetFrame = self.containerNode.frame self.containerNode.layer.animateFrame(from: CGRect(origin: targetFrame.origin, size: CGSize(width: 1.0, height: targetFrame.height)), to: targetFrame, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) self.backgroundNode.layer.animateFrame(from: CGRect(origin: targetFrame.origin, size: CGSize(width: 1.0, height: targetFrame.height)), to: targetFrame, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) if let avatarNode = self.avatarNode { avatarNode.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) avatarNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } self.iconNode.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) self.titleNode.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) self.titleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } func animateOut() { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { [weak self] _ in self?.removeFromSupernode() }) self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) } func update(theme: SearchBarNodeTheme, token: SearchBarToken, isSelected: Bool, isCollapsed: Bool) { let wasSelected = self.isSelected self.isSelected = isSelected self.isCollapsed = isCollapsed if theme !== self.theme || isSelected != wasSelected { let backgroundColor: UIColor if isSelected { backgroundColor = self.theme.accent } else { backgroundColor = token.isTag ? theme.inputIcon.withMultipliedAlpha(0.2) : (token.style?.backgroundColor ?? self.theme.inputIcon) } let strokeColor = isSelected ? backgroundColor : (token.style?.strokeColor ?? backgroundColor) if token.isTag { self.backgroundNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/SearchTagTokenBackground"), color: backgroundColor)?.stretchableImage(withLeftCapWidth: 7, topCapHeight: 0) } else { self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 8.0, color: backgroundColor, strokeColor: strokeColor, strokeWidth: UIScreenPixel, backgroundColor: nil) } var foregroundColor = isSelected ? .white : (token.isTag ? self.theme.primaryText : (token.style?.foregroundColor ?? .white)) if foregroundColor.distance(to: backgroundColor) < 1 { foregroundColor = .black } if let image = token.icon { self.iconNode.image = generateTintedImage(image: image, color: foregroundColor) } self.titleNode.attributedText = NSAttributedString(string: token.title, font: Font.regular(token.isTag ? 14.0 : 17.0), textColor: foregroundColor) } } func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize { var height: CGFloat = 24.0 if self.token.isTag { height += 2.0 } var emojiFileSize: CGSize? var leftInset: CGFloat = 3.0 if let icon = self.iconNode.image { leftInset += 1.0 var iconFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - icon.size.height) / 2.0)), size: icon.size) if let iconOffset = self.token.iconOffset { iconFrame.origin.x += iconOffset } transition.updateFrame(node: self.iconNode, frame: iconFrame) leftInset += icon.size.width + 3.0 } if let emojiFile = self.token.emojiFile, let context = self.token.context { let emojiView: ComponentView if let current = self.emojiView { emojiView = current } else { emojiView = ComponentView() self.emojiView = emojiView } let emojiSize = CGSize(width: 14.0, height: 14.0) var visibleEmojiSize = emojiSize if case .builtin = self.token.reaction { visibleEmojiSize = CGSize(width: visibleEmojiSize.width * 2.0, height: visibleEmojiSize.height * 2.0) } let _ = emojiView.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( context: context, animationCache: context.animationCache, animationRenderer: context.animationRenderer, content: .animation( content: .file(file: emojiFile), size: visibleEmojiSize, placeholderColor: self.theme.primaryText.withMultipliedAlpha(0.2), themeColor: self.theme.primaryText, loopMode: .forever ), isVisibleForAnimations: false, useSharedAnimation: true, action: nil, emojiFileUpdated: nil )), environment: {}, containerSize: visibleEmojiSize ) if let emojiComponentView = emojiView.view { if emojiComponentView.superview == nil { self.containerNode.view.addSubview(emojiComponentView) } let emojiFrame = CGRect(origin: CGPoint(x: leftInset + 2.0, y: floor((height - emojiSize.height) * 0.5)), size: emojiSize) emojiComponentView.frame = visibleEmojiSize.centered(around: emojiFrame.center) } emojiFileSize = emojiSize } if self.token.isTag { leftInset += 2.0 } let iconSize = self.token.icon?.size ?? CGSize() let titleSize = self.titleNode.measure(CGSize(width: constrainedSize.width - 6.0, height: constrainedSize.height)) var width = titleSize.width + 6.0 if !iconSize.width.isZero { width += iconSize.width + 7.0 } if let emojiFileSize { leftInset += emojiFileSize.width + 6.0 width += emojiFileSize.width + 6.0 } if self.token.isTag { width += 16.0 } let size: CGSize if let avatarNode = self.avatarNode { size = CGSize(width: height, height: height) transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: size)) transition.updateFrame(node: avatarNode, frame: CGRect(origin: CGPoint(), size: size)) } else { size = CGSize(width: self.isCollapsed ? height : width, height: height) transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: size)) transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size)) transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize)) } return size } } private class SearchBarTextField: UITextField, UIScrollViewDelegate { public var didDeleteBackward: (() -> Bool)? let placeholderLabel: ImmediateTextNode var placeholderString: NSAttributedString? { didSet { self.placeholderLabel.attributedText = self.placeholderString self.setNeedsLayout() } } var clippingNode: PassthroughContainerNode var tokenContainerNode: PassthroughContainerNode var tokenNodes: [AnyHashable: TokenNode] = [:] var tokens: [SearchBarToken] = [] { didSet { self._selectedTokenIndex = nil self.layoutTokens(transition: .animated(duration: 0.2, curve: .easeInOut)) self.setNeedsLayout() self.updateCursorColor() } } var _selectedTokenIndex: Int? var selectedTokenIndex: Int? { get { return self._selectedTokenIndex } set { _selectedTokenIndex = newValue self.layoutTokens(transition: .animated(duration: 0.2, curve: .easeInOut)) self.setNeedsLayout() self.updateCursorColor() } } private func updateCursorColor() { if self._selectedTokenIndex != nil { super.tintColor = UIColor.clear } else { super.tintColor = self._tintColor } } var _tintColor: UIColor = .black override var tintColor: UIColor! { get { return super.tintColor } set { if newValue != UIColor.clear { self._tintColor = newValue if self.selectedTokenIndex == nil { super.tintColor = newValue } } } } var theme: SearchBarNodeTheme let style: SearchBarStyle fileprivate func layoutTokens(transition: ContainedViewLayoutTransition = .immediate) { var hasSelected = false for i in 0 ..< self.tokens.count { let token = self.tokens[i] let tokenNode: TokenNode if let current = self.tokenNodes[token.id] { tokenNode = current } else { tokenNode = TokenNode(theme: self.theme, token: token) self.tokenNodes[token.id] = tokenNode } tokenNode.tapped = { [weak self] in if let strongSelf = self { strongSelf.selectedTokenIndex = i if !strongSelf.isFirstResponder { let _ = strongSelf.becomeFirstResponder() } else { let newPosition = strongSelf.beginningOfDocument strongSelf.selectedTextRange = strongSelf.textRange(from: newPosition, to: newPosition) } } } let isSelected = i == self.selectedTokenIndex if i < self.tokens.count - 1 && isSelected { hasSelected = true } let isCollapsed = !isSelected && (token.permanent || (i < self.tokens.count - 1 || hasSelected)) tokenNode.update(theme: self.theme, token: token, isSelected: isSelected, isCollapsed: isCollapsed) } var removeKeys: [AnyHashable] = [] for (id, _) in self.tokenNodes { if !self.tokens.contains(where: { $0.id == id }) { removeKeys.append(id) } } for id in removeKeys { if let itemNode = self.tokenNodes.removeValue(forKey: id) { if transition.isAnimated { itemNode.animateOut() } else { itemNode.removeFromSupernode() } } } var tokenSizes: [(AnyHashable, CGSize, TokenNode, Bool)] = [] var totalRawTabSize: CGFloat = 0.0 for token in self.tokens { guard let tokenNode = self.tokenNodes[token.id] else { continue } let wasAdded = tokenNode.view.superview == nil var tokenNodeTransition = transition if wasAdded { tokenNodeTransition = .immediate self.tokenContainerNode.addSubnode(tokenNode) } let constrainedSize = CGSize(width: self.bounds.size.width - 90.0, height: self.bounds.size.height) let nodeSize = tokenNode.updateLayout(constrainedSize: constrainedSize, transition: tokenNodeTransition) tokenSizes.append((token.id, nodeSize, tokenNode, wasAdded)) totalRawTabSize += nodeSize.width } let minSpacing: CGFloat = 6.0 let resolvedSideInset: CGFloat = 0.0 var leftOffset: CGFloat = 0.0 if !tokenSizes.isEmpty { leftOffset += resolvedSideInset } var longTitlesWidth: CGFloat = resolvedSideInset for i in 0 ..< tokenSizes.count { let (_, paneNodeSize, _, _) = tokenSizes[i] longTitlesWidth += paneNodeSize.width if i != tokenSizes.count - 1 { longTitlesWidth += minSpacing } } longTitlesWidth += resolvedSideInset if !tokenSizes.isEmpty { leftOffset -= 8.0 } let verticalOffset: CGFloat = 0.0 var horizontalOffset: CGFloat = 0.0 for i in 0 ..< tokenSizes.count { let (_, nodeSize, tokenNode, wasAdded) = tokenSizes[i] let tokenNodeTransition = transition let nodeFrame = CGRect(origin: CGPoint(x: leftOffset, y: floor((self.frame.height - nodeSize.height) / 2.0) + verticalOffset), size: nodeSize) if wasAdded { if horizontalOffset > 0.0 { tokenNode.frame = nodeFrame.offsetBy(dx: horizontalOffset, dy: 0.0) tokenNodeTransition.updatePosition(node: tokenNode, position: nodeFrame.center) } else { tokenNode.frame = nodeFrame } tokenNode.animateIn() } else { if nodeFrame.width < tokenNode.frame.width { horizontalOffset += tokenNode.frame.width - nodeFrame.width } tokenNodeTransition.updateFrame(node: tokenNode, frame: nodeFrame) } tokenNode.hitTestSlop = UIEdgeInsets(top: 0.0, left: -minSpacing / 2.0, bottom: 0.0, right: -minSpacing / 2.0) leftOffset += nodeSize.width + minSpacing } if !tokenSizes.isEmpty { leftOffset += 4.0 } let previousTokensWidth = self.tokensWidth self.tokensWidth = leftOffset self.tokenContainerNode.frame = CGRect(origin: self.tokenContainerNode.frame.origin, size: CGSize(width: self.tokensWidth, height: self.bounds.height)) if let scrollView = self.scrollView { if scrollView.contentInset.left != leftOffset { scrollView.contentInset = UIEdgeInsets(top: 0.0, left: leftOffset, bottom: 0.0, right: 0.0) } if leftOffset.isZero { scrollView.contentOffset = CGPoint() } else if self.tokensWidth != previousTokensWidth { scrollView.contentOffset = CGPoint(x: -leftOffset, y: 0.0) } self.updateTokenContainerPosition(transition: transition) } } fileprivate var tokensWidth: CGFloat = 0.0 private let measurePrefixLabel: ImmediateTextNode let prefixLabel: ImmediateTextNode var prefixString: NSAttributedString? { didSet { self.measurePrefixLabel.attributedText = self.prefixString self.prefixLabel.attributedText = self.prefixString self.setNeedsLayout() } } init(theme: SearchBarNodeTheme, style: SearchBarStyle) { self.theme = theme self.style = style self.placeholderLabel = ImmediateTextNode() self.placeholderLabel.isUserInteractionEnabled = false self.placeholderLabel.displaysAsynchronously = false self.placeholderLabel.maximumNumberOfLines = 1 self.placeholderLabel.truncationMode = .byTruncatingTail self.measurePrefixLabel = ImmediateTextNode() self.measurePrefixLabel.isUserInteractionEnabled = false self.measurePrefixLabel.displaysAsynchronously = false self.measurePrefixLabel.maximumNumberOfLines = 1 self.measurePrefixLabel.truncationMode = .byTruncatingTail self.prefixLabel = ImmediateTextNode() self.prefixLabel.isUserInteractionEnabled = false self.prefixLabel.displaysAsynchronously = false self.prefixLabel.maximumNumberOfLines = 1 self.prefixLabel.truncationMode = .byTruncatingTail self.clippingNode = PassthroughContainerNode() self.clippingNode.clipsToBounds = true self.tokenContainerNode = PassthroughContainerNode() super.init(frame: CGRect()) if case .glass = style { } else { self.addSubnode(self.placeholderLabel) } self.addSubnode(self.prefixLabel) self.addSubnode(self.clippingNode) self.clippingNode.addSubnode(self.tokenContainerNode) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func addSubview(_ view: UIView) { super.addSubview(view) if let scrollView = view as? UIScrollView { scrollView.delegate = self self.bringSubviewToFront(self.clippingNode.view) } } private weak var _scrollView: UIScrollView? var scrollView: UIScrollView? { if let scrollView = self._scrollView { return scrollView } for view in self.subviews { if let scrollView = view as? UIScrollView { _scrollView = scrollView return scrollView } } return nil } var fixAutoScroll: CGPoint? func scrollViewDidScroll(_ scrollView: UIScrollView) { if let fixAutoScroll = self.fixAutoScroll { self.scrollView?.setContentOffset(fixAutoScroll, animated: true) self.scrollView?.setContentOffset(fixAutoScroll, animated: false) self.fixAutoScroll = nil } else { self.updateTokenContainerPosition() } } override func becomeFirstResponder() -> Bool { if let contentOffset = self.scrollView?.contentOffset { self.fixAutoScroll = contentOffset Queue.mainQueue().after(0.1) { self.fixAutoScroll = nil } } return super.becomeFirstResponder() } private func updateTokenContainerPosition(transition: ContainedViewLayoutTransition = .immediate) { if let scrollView = self.scrollView { transition.updateFrame(node: self.tokenContainerNode, frame: CGRect(origin: CGPoint(x: -scrollView.contentOffset.x - scrollView.contentInset.left, y: 0.0), size: self.tokenContainerNode.frame.size)) } } override var keyboardAppearance: UIKeyboardAppearance { get { return super.keyboardAppearance } set { let resigning = self.isFirstResponder if resigning { self.resignFirstResponder() } super.keyboardAppearance = newValue if resigning { let _ = self.becomeFirstResponder() } } } override func textRect(forBounds bounds: CGRect) -> CGRect { if bounds.size.width.isZero { return CGRect(origin: CGPoint(), size: CGSize()) } var rect = bounds.insetBy(dx: 7.0, dy: 4.0) if #available(iOS 14.0, *) { } else { rect.origin.y += 1.0 } let prefixSize = self.measurePrefixLabel.updateLayout(CGSize(width: floor(bounds.size.width * 0.7), height: bounds.size.height)) if !prefixSize.width.isZero { let prefixOffset = prefixSize.width + 3.0 rect.origin.x += prefixOffset rect.size.width -= prefixOffset } if !self.tokensWidth.isZero && self.scrollView?.superview == nil { var offset = self.tokensWidth if let scrollView = self.scrollView { offset = scrollView.contentOffset.x * -1.0 } rect.origin.x += offset rect.size.width -= offset } rect.size.width = max(rect.size.width, 10.0) return rect } override func editingRect(forBounds bounds: CGRect) -> CGRect { return self.textRect(forBounds: bounds) } override func layoutSubviews() { super.layoutSubviews() let bounds = self.bounds self.clippingNode.frame = CGRect(x: 10.0, y: 0.0, width: bounds.width - 20.0, height: bounds.height) if bounds.size.width.isZero { return } var textOffset: CGFloat = 1.0 if bounds.height >= 36.0 { textOffset += 2.0 } var placeholderXOffset: CGFloat = 0.0 if self.tokensWidth > 0.0, self.scrollView?.superview != nil { placeholderXOffset = self.tokensWidth + 8.0 } var placeholderYOffset: CGFloat = 0.0 if #available(iOS 14.0, *) { placeholderYOffset = 1.0 } else { } let textRect = self.textRect(forBounds: bounds) let labelSize = self.placeholderLabel.updateLayout(textRect.size) switch self.style { case .glass, .inlineNavigation: placeholderYOffset += 0.0 case .legacy, .modern: break } self.placeholderLabel.frame = CGRect(origin: CGPoint(x: textRect.minX + placeholderXOffset, y: floorToScreenPixels(bounds.height - labelSize.height) * 0.5), size: labelSize) let prefixSize = self.prefixLabel.updateLayout(CGSize(width: floor(bounds.size.width * 0.7), height: bounds.size.height)) let prefixBounds = bounds.insetBy(dx: 4.0, dy: 4.0) self.prefixLabel.frame = CGRect(origin: CGPoint(x: prefixBounds.minX, y: floorToScreenPixels(bounds.height - prefixSize.height) * 0.5), size: prefixSize) } override func deleteBackward() { var processed = false if let selectedRange = self.selectedTextRange { let cursorPosition = self.offset(from: self.beginningOfDocument, to: selectedRange.start) if cursorPosition == 0 && selectedRange.isEmpty && !self.tokens.isEmpty && self.selectedTokenIndex == nil { self.selectedTokenIndex = self.tokens.count - 1 processed = true } } if !processed { processed = self.didDeleteBackward?() ?? false } if !processed { super.deleteBackward() if let scrollView = self.scrollView { if scrollView.contentSize.width <= scrollView.frame.width && scrollView.contentOffset.x > -scrollView.contentInset.left { scrollView.contentOffset = CGPoint(x: max(scrollView.contentOffset.x - 5.0, -scrollView.contentInset.left), y: 0.0) self.updateTokenContainerPosition() } } } } override func touchesBegan(_ touches: Set, with event: UIEvent?) { if let _ = self.selectedTokenIndex { if let touch = touches.first, let gestureRecognizers = touch.gestureRecognizers { let point = touch.location(in: self.tokenContainerNode.view) for (_, tokenNode) in self.tokenNodes { if tokenNode.frame.contains(point) { super.touchesBegan(touches, with: event) return } } self.selectedTokenIndex = nil for gesture in gestureRecognizers { if gesture is UITapGestureRecognizer, gesture.isEnabled { gesture.isEnabled = false gesture.isEnabled = true } } } } else { super.touchesBegan(touches, with: event) } } } public final class SearchBarNodeTheme: Equatable { public let background: UIColor public let separator: UIColor public let inputFill: UIColor public let placeholder: UIColor public let primaryText: UIColor public let inputIcon: UIColor public let inputClear: UIColor public let accent: UIColor public let keyboard: PresentationThemeKeyboardColor public init(background: UIColor, separator: UIColor, inputFill: UIColor, primaryText: UIColor, placeholder: UIColor, inputIcon: UIColor, inputClear: UIColor, accent: UIColor, keyboard: PresentationThemeKeyboardColor) { self.background = background self.separator = separator self.inputFill = inputFill self.primaryText = primaryText self.placeholder = placeholder self.inputIcon = inputIcon self.inputClear = inputClear self.accent = accent self.keyboard = keyboard } public init(theme: PresentationTheme, hasBackground: Bool = true, hasSeparator: Bool = true, inline: Bool = false) { self.background = hasBackground ? theme.rootController.navigationBar.blurredBackgroundColor : .clear self.separator = hasSeparator ? theme.rootController.navigationBar.separatorColor : theme.rootController.navigationBar.blurredBackgroundColor var fillColor = theme.rootController.navigationSearchBar.inputFillColor if inline, fillColor.distance(to: theme.list.blocksBackgroundColor) < 100 { fillColor = fillColor.withMultipliedBrightnessBy(0.8) } self.inputFill = fillColor self.placeholder = theme.rootController.navigationSearchBar.inputPlaceholderTextColor self.primaryText = theme.rootController.navigationSearchBar.inputTextColor self.inputIcon = theme.rootController.navigationSearchBar.inputIconColor self.inputClear = theme.rootController.navigationSearchBar.inputClearButtonColor self.accent = theme.rootController.navigationSearchBar.accentColor self.keyboard = theme.rootController.keyboardColor } public static func ==(lhs: SearchBarNodeTheme, rhs: SearchBarNodeTheme) -> Bool { if lhs.background != rhs.background { return false } if lhs.separator != rhs.separator { return false } if lhs.inputFill != rhs.inputFill { return false } if lhs.placeholder != rhs.placeholder { return false } if lhs.primaryText != rhs.primaryText { return false } if lhs.inputIcon != rhs.inputIcon { return false } if lhs.inputClear != rhs.inputClear { return false } if lhs.accent != rhs.accent { return false } if lhs.keyboard != rhs.keyboard { return false } return true } } public enum SearchBarStyle { case modern case legacy case inlineNavigation case glass var font: UIFont { switch self { case .modern, .inlineNavigation, .glass: return Font.regular(17.0) case .legacy: return Font.regular(14.0) } } var cornerDiameter: CGFloat { switch self { case .modern, .inlineNavigation: return 21.0 case .glass: return 22.0 case .legacy: return 14.0 } } var height: CGFloat { switch self { case .inlineNavigation: return 48.0 case .glass: return 44.0 case .modern: return 36.0 case .legacy: return 28.0 } } var padding: CGFloat { switch self { case .inlineNavigation: return 0.0 case .glass: return 20.0 case .modern: return 10.0 case .legacy: return 8.0 } } } public class SearchBarNode: ASDisplayNode, UITextFieldDelegate { public enum Icon { case loupe case hashtag case cashtag } public let icon: Icon public var cancel: (() -> Void)? public var textUpdated: ((String, String?) -> Void)? public var textReturned: ((String) -> Void)? public var clearPrefix: (() -> Void)? public var clearTokens: (() -> Void)? public var focusUpdated: ((Bool) -> Void)? public var tokensUpdated: (([SearchBarToken]) -> Void)? private let inlineSearchPlaceholder: SearchBarPlaceholderNode private var inlineSearchPlaceholderContentsView: SearchBarPlaceholderContentView? private let backgroundNode: NavigationBackgroundNode private let separatorNode: ASDisplayNode private let textBackgroundNode: ASDisplayNode private var activityIndicator: ActivityIndicator? private let iconNode: ASImageNode private let textField: SearchBarTextField private let clearButton: HighlightableButtonNode private let cancelButton: HighlightableButtonNode private var takenSearchPlaceholderContentView: SearchBarPlaceholderContentView? public var placeholderString: NSAttributedString? { get { return self.textField.placeholderString } set(value) { self.textField.placeholderString = value } } public var tokens: [SearchBarToken] { get { return self.textField.tokens } set { self.textField.tokens = newValue self.updateIsEmpty(animated: true) } } public var prefixString: NSAttributedString? { get { return self.textField.prefixString } set(value) { let previous = self.prefixString let updated: Bool if let previous = previous, let value = value { updated = !previous.isEqual(to: value) } else { updated = (previous != nil) != (value != nil) } if updated { self.textField.prefixString = value self.textField.setNeedsLayout() self.updateIsEmpty() } } } public var text: String { get { return self.textField.text ?? "" } set(value) { if self.textField.text ?? "" != value { self.textField.text = value self.textFieldDidChange(self.textField) } } } public var activity: Bool = false { didSet { if self.activity != oldValue { if self.activity { if self.activityIndicator == nil, let theme = self.theme { let activityIndicator = ActivityIndicator(type: .custom(theme.inputIcon, 13.0, 1.0, false)) self.activityIndicator = activityIndicator self.addSubnode(activityIndicator) if let (boundingSize, leftInset, rightInset) = self.validLayout { self.updateLayout(boundingSize: boundingSize, leftInset: leftInset, rightInset: rightInset, transition: .immediate) } } } else if let activityIndicator = self.activityIndicator { self.activityIndicator = nil activityIndicator.removeFromSupernode() } self.iconNode.isHidden = self.activity if let takenSearchPlaceholderContentView = self.takenSearchPlaceholderContentView { takenSearchPlaceholderContentView.updateSearchIconVisibility(isVisible: !self.activity) } if let inlineSearchPlaceholderContentsView = self.inlineSearchPlaceholderContentsView { inlineSearchPlaceholderContentsView.updateSearchIconVisibility(isVisible: !self.activity) } } } } public var hasCancelButton: Bool = true { didSet { self.cancelButton.isHidden = !self.hasCancelButton if let (boundingSize, leftInset, rightInset) = self.validLayout { self.updateLayout(boundingSize: boundingSize, leftInset: leftInset, rightInset: rightInset, transition: .immediate) } } } public var autocapitalization: UITextAutocapitalizationType { get { return self.textField.autocapitalizationType } set { self.textField.autocapitalizationType = newValue } } private var validLayout: (CGSize, CGFloat, CGFloat)? public let fieldStyle: SearchBarStyle private let forceSeparator: Bool private var theme: SearchBarNodeTheme? private var presentationTheme: PresentationTheme private var strings: PresentationStrings? private let cancelText: String? private var isAnimatingOut: Bool = false public init(theme: SearchBarNodeTheme, presentationTheme: PresentationTheme, strings: PresentationStrings, fieldStyle: SearchBarStyle = .legacy, icon: Icon = .loupe, forceSeparator: Bool = false, displayBackground: Bool = true, cancelText: String? = nil) { self.presentationTheme = presentationTheme self.fieldStyle = fieldStyle self.forceSeparator = forceSeparator self.cancelText = cancelText self.icon = icon self.inlineSearchPlaceholder = SearchBarPlaceholderNode(fieldStyle: .glass) self.backgroundNode = NavigationBackgroundNode(color: theme.background) self.backgroundNode.isUserInteractionEnabled = false self.backgroundNode.isHidden = !displayBackground self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true self.textBackgroundNode = ASDisplayNode() self.textBackgroundNode.isLayerBacked = false self.textBackgroundNode.displaysAsynchronously = false self.textBackgroundNode.cornerRadius = self.fieldStyle.cornerDiameter / 2.0 self.iconNode = ASImageNode() self.iconNode.isLayerBacked = true self.iconNode.displaysAsynchronously = false self.iconNode.displayWithoutProcessing = true self.textField = SearchBarTextField(theme: theme, style: fieldStyle) self.textField.accessibilityTraits = .searchField self.textField.autocorrectionType = .no self.textField.returnKeyType = .search self.textField.font = self.fieldStyle.font self.clearButton = HighlightableButtonNode(pointerStyle: .lift) self.clearButton.imageNode.displaysAsynchronously = false self.clearButton.imageNode.displayWithoutProcessing = true self.clearButton.displaysAsynchronously = false self.cancelButton = HighlightableButtonNode(pointerStyle: .default) self.cancelButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) self.cancelButton.displaysAsynchronously = false super.init() switch self.fieldStyle { case .glass: break case .inlineNavigation: break case .legacy, .modern: self.addSubnode(self.backgroundNode) self.addSubnode(self.separatorNode) self.addSubnode(self.textBackgroundNode) } self.view.addSubview(self.textField) switch self.fieldStyle { case .glass, .inlineNavigation: break case .legacy, .modern: self.addSubnode(self.iconNode) self.addSubnode(self.cancelButton) } self.addSubnode(self.clearButton) self.textField.delegate = self self.textField.addTarget(self, action: #selector(self.textFieldDidChange(_:)), for: .editingChanged) self.textField.didDeleteBackward = { [weak self] in guard let strongSelf = self else { return false } if let index = strongSelf.textField.selectedTokenIndex { if !strongSelf.tokens[index].permanent { strongSelf.tokens.remove(at: index) strongSelf.tokensUpdated?(strongSelf.tokens) } return true } else if strongSelf.text.isEmpty { strongSelf.clearPressed() return true } return false } self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside) self.clearButton.addTarget(self, action: #selector(self.clearPressed), forControlEvents: .touchUpInside) self.updateThemeAndStrings(theme: theme, presentationTheme: presentationTheme, strings: strings) self.updateIsEmpty(animated: false) } public func updateThemeAndStrings(theme: SearchBarNodeTheme, presentationTheme: PresentationTheme, strings: PresentationStrings) { if self.theme != theme || self.strings !== strings { self.clearButton.accessibilityLabel = strings.WebSearch_RecentSectionClear self.cancelButton.accessibilityLabel = self.cancelText ?? strings.Common_Cancel self.cancelButton.setAttributedTitle(NSAttributedString(string: self.cancelText ?? strings.Common_Cancel, font: self.cancelText != nil ? Font.semibold(17.0) : Font.regular(17.0), textColor: theme.accent), for: []) } if self.theme != theme { self.backgroundNode.updateColor(color: theme.background, transition: .immediate) if self.fieldStyle != .modern || self.forceSeparator { self.separatorNode.backgroundColor = theme.separator } self.textBackgroundNode.backgroundColor = theme.inputFill self.textField.textColor = theme.primaryText self.clearButton.setImage(generateClearIcon(color: theme.inputClear), for: []) let icon: UIImage? switch self.icon { case .loupe: icon = generateLoupeIcon(color: theme.inputIcon) case .hashtag: icon = generateHashtagIcon(color: theme.inputIcon) case .cashtag: icon = generateCashtagIcon(color: theme.inputIcon) } self.iconNode.image = icon self.textField.keyboardAppearance = theme.keyboard.keyboardAppearance self.textField.tintColor = theme.accent if let activityIndicator = self.activityIndicator { activityIndicator.type = .custom(theme.inputIcon, 13.0, 1.0, false) } } self.theme = theme self.presentationTheme = presentationTheme self.strings = strings if let (boundingSize, leftInset, rightInset) = self.validLayout { self.updateLayout(boundingSize: boundingSize, leftInset: leftInset, rightInset: rightInset, transition: .immediate) } } public func updateLayout(boundingSize: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { self.validLayout = (boundingSize, leftInset, rightInset) self.backgroundNode.frame = self.bounds self.backgroundNode.update(size: self.backgroundNode.bounds.size, transition: .immediate) transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: self.bounds.size.height), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel))) let contentFrame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: boundingSize.width - leftInset - rightInset, height: boundingSize.height)) let textBackgroundHeight: CGFloat if case .inlineNavigation = self.fieldStyle { textBackgroundHeight = boundingSize.height } else { textBackgroundHeight = self.fieldStyle.height } let verticalOffset: CGFloat switch self.fieldStyle { case .inlineNavigation, .glass: verticalOffset = -textBackgroundHeight case .legacy, .modern: verticalOffset = boundingSize.height - 82.0 } let cancelButtonSize = self.cancelButton.measure(CGSize(width: 100.0, height: CGFloat.infinity)) transition.updateFrame(node: self.cancelButton, frame: CGRect(origin: CGPoint(x: contentFrame.maxX - 10.0 - cancelButtonSize.width, y: verticalOffset + textBackgroundHeight + floorToScreenPixels((textBackgroundHeight - cancelButtonSize.height) / 2.0)), size: cancelButtonSize)) let padding = self.fieldStyle.padding var textBackgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX + padding, y: verticalOffset + textBackgroundHeight), size: CGSize(width: contentFrame.width - padding - (self.hasCancelButton ? cancelButtonSize.width + 11.0 : 0.0), height: textBackgroundHeight)) if case .glass = self.fieldStyle { textBackgroundFrame.size.width -= 8.0 } else { textBackgroundFrame.size.width -= padding } transition.updateFrame(node: self.textBackgroundNode, frame: textBackgroundFrame) var textFrame = CGRect(origin: CGPoint(x: 0.0, y: textBackgroundFrame.minY), size: CGSize(width: max(1.0, textBackgroundFrame.size.width - 24.0 - 27.0), height: textBackgroundFrame.size.height)) if case .inlineNavigation = self.fieldStyle { textFrame.size.width = boundingSize.width - 27.0 textBackgroundFrame.size.width = boundingSize.width } else { textFrame.origin.x = textBackgroundFrame.minX + 24.0 } if let iconImage = self.iconNode.image { let iconSize = iconImage.size transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 5.0, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - iconSize.height) / 2.0) - UIScreenPixel), size: iconSize)) } if let activityIndicator = self.activityIndicator { let indicatorSize = activityIndicator.measure(CGSize(width: 32.0, height: 32.0)) transition.updateFrame(node: activityIndicator, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 9.0 + UIScreenPixel, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - indicatorSize.height) / 2.0)), size: indicatorSize)) } let clearSize = self.clearButton.measure(CGSize(width: 100.0, height: 100.0)) transition.updateFrame(node: self.clearButton, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.maxX - 6.0 - clearSize.width, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - clearSize.height) / 2.0)), size: clearSize)) self.textField.frame = textFrame let searchPlaceholderFrame = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: CGSize(width: max(0.0, boundingSize.width - 16.0 * 2.0), height: 44.0)) if case .glass = self.fieldStyle, self.takenSearchPlaceholderContentView == nil { transition.updateFrame(node: self.inlineSearchPlaceholder, frame: searchPlaceholderFrame) if let theme = self.theme { let _ = self.inlineSearchPlaceholder.updateLayout( placeholderString: self.placeholderString, compactPlaceholderString: self.placeholderString, constrainedSize: searchPlaceholderFrame.size, expansionProgress: 1.0, iconColor: theme.inputIcon, foregroundColor: self.presentationTheme.chat.inputPanel.panelControlColor, backgroundColor: self.presentationTheme.rootController.navigationBar.opaqueBackgroundColor, controlColor: self.presentationTheme.chat.inputPanel.panelControlColor, transition: transition ) if self.inlineSearchPlaceholderContentsView == nil { let inlineSearchPlaceholderContentsView = self.inlineSearchPlaceholder.takeContents() inlineSearchPlaceholderContentsView.onCancel = { [weak self] in guard let self else { return } self.cancel?() } self.inlineSearchPlaceholderContentsView = inlineSearchPlaceholderContentsView self.view.insertSubview(inlineSearchPlaceholderContentsView, at: 0) } } if let inlineSearchPlaceholderContentsView = self.inlineSearchPlaceholderContentsView { inlineSearchPlaceholderContentsView.update(size: searchPlaceholderFrame.size, isActive: true, transition: transition) transition.updateFrame(view: inlineSearchPlaceholderContentsView, frame: searchPlaceholderFrame) } } if !self.isAnimatingOut, let takenSearchPlaceholderContentView = self.takenSearchPlaceholderContentView { transition.updateFrame(view: takenSearchPlaceholderContentView, frame: searchPlaceholderFrame) takenSearchPlaceholderContentView.update(size: searchPlaceholderFrame.size, isActive: true, transition: transition) } } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { if let cancel = self.cancel { cancel() } } } public func activate() { if !self.textField.isFirstResponder { let _ = self.textField.becomeFirstResponder() } } public func animateIn(from node: SearchBarPlaceholderNode, duration: Double, timingFunction: String) { self.inlineSearchPlaceholder.isHidden = true let takenSearchPlaceholderContentView = node.takeContents() takenSearchPlaceholderContentView.onCancel = { [weak self] in guard let self else { return } self.cancel?() } self.takenSearchPlaceholderContentView = takenSearchPlaceholderContentView self.view.insertSubview(takenSearchPlaceholderContentView, at: 0) if let inlineSearchPlaceholderContentsView = self.inlineSearchPlaceholderContentsView { inlineSearchPlaceholderContentsView.removeFromSuperview() } let sourceFrame = node.view.convert(node.bounds, to: self.view) let targetFrame = CGRect(origin: CGPoint(x: 16.0, y: 0.0), size: CGSize(width: max(0.0, self.bounds.width - 16.0 * 2.0), height: 44.0)) let transition: ContainedViewLayoutTransition = .animated(duration: duration, curve: timingFunction == kCAMediaTimingFunctionSpring ? .spring : .easeInOut) takenSearchPlaceholderContentView.frame = sourceFrame transition.updateFrame(view: takenSearchPlaceholderContentView, frame: targetFrame) takenSearchPlaceholderContentView.update(size: targetFrame.size, isActive: true, transition: transition) /*let initialTextBackgroundFrame = node.view.convert(node.backgroundView.frame, to: self.view) let initialBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.size.width, height: max(0.0, initialTextBackgroundFrame.maxY + 8.0))) if let fromBackgroundColor = node.backgroundColor, let toBackgroundColor = self.backgroundNode.backgroundColor { self.backgroundNode.layer.animate(from: fromBackgroundColor.cgColor, to: toBackgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration * 0.7) } else { self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) } self.backgroundNode.layer.animateFrame(from: initialBackgroundFrame, to: self.backgroundNode.frame, duration: duration, timingFunction: timingFunction) let initialSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, initialTextBackgroundFrame.maxY + 8.0)), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel)) self.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) self.separatorNode.layer.animateFrame(from: initialSeparatorFrame, to: self.separatorNode.frame, duration: duration, timingFunction: timingFunction) if let fromTextBackgroundColor = node.backgroundView.backgroundColor, let toTextBackgroundColor = self.textBackgroundNode.backgroundColor { self.textBackgroundNode.layer.animate(from: fromTextBackgroundColor.cgColor, to: toTextBackgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: timingFunction, duration: duration * 1.0) } self.textBackgroundNode.layer.animateFrame(from: initialTextBackgroundFrame, to: self.textBackgroundNode.frame, duration: duration, timingFunction: timingFunction) if initialTextBackgroundFrame.height.isZero { self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) self.textField.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } let textFieldFrame = self.textField.frame var tokensWidth = self.textField.tokensWidth if tokensWidth > 0.0 { tokensWidth += 8.0 } let initialLabelNodeFrame = CGRect(origin: node.labelNode.frame.offsetBy(dx: initialTextBackgroundFrame.origin.x - 7.0 - tokensWidth, dy: initialTextBackgroundFrame.origin.y - 8.0).origin, size: textFieldFrame.size) self.textField.layer.animateFrame(from: initialLabelNodeFrame, to: self.textField.frame, duration: duration, timingFunction: timingFunction) let iconFrame = self.iconNode.frame let initialIconFrame = CGRect(origin: node.iconNode.frame.offsetBy(dx: initialTextBackgroundFrame.origin.x, dy: initialTextBackgroundFrame.origin.y).origin, size: iconFrame.size) self.iconNode.layer.animateFrame(from: initialIconFrame, to: self.iconNode.frame, duration: duration, timingFunction: timingFunction) let cancelButtonFrame = self.cancelButton.frame self.cancelButton.layer.animatePosition(from: CGPoint(x: self.bounds.size.width + cancelButtonFrame.size.width / 2.0, y: initialTextBackgroundFrame.midY), to: self.cancelButton.layer.position, duration: duration, timingFunction: timingFunction) node.isHidden = true*/ } public func deactivate(clear: Bool = true) { self.textField.resignFirstResponder() if clear { self.textField.text = nil self.textField.tokens = [] self.textField.prefixString = nil self.textField.placeholderLabel.alpha = 1.0 } } public func transitionOut(to node: SearchBarPlaceholderNode, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { self.isAnimatingOut = true /*let targetTextBackgroundFrame = node.view.convert(node.backgroundView.frame, to: self.view) let duration: Double = transition.isAnimated ? 0.5 : 0.0 let timingFunction = kCAMediaTimingFunctionSpring node.isHidden = true self.textField.isUserInteractionEnabled = false if !self.clearButton.isHidden { let xOffset = targetTextBackgroundFrame.width - self.textBackgroundNode.frame.width if !xOffset.isZero { self.clearButton.layer.animatePosition(from: .zero, to: CGPoint(x: xOffset, y: 0.0), duration: duration, timingFunction: timingFunction, additive: true) } self.clearButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in self.clearButton.isHidden = true self.clearButton.layer.removeAllAnimations() }) } self.activityIndicator?.isHidden = true self.iconNode.isHidden = false var tokensWidth = self.textField.tokensWidth if tokensWidth > 0.0 { tokensWidth += 8.0 } let textFieldFrame = self.textField.frame let targetLabelNodeFrame = CGRect(origin: CGPoint(x: node.labelNode.frame.minX + targetTextBackgroundFrame.origin.x - 7.0 - tokensWidth, y: targetTextBackgroundFrame.minY + floorToScreenPixels((targetTextBackgroundFrame.size.height - textFieldFrame.size.height) / 2.0) - UIScreenPixel), size: textFieldFrame.size) self.textField.layer.animateFrame(from: textFieldFrame, to: targetLabelNodeFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) if !self.textField.tokenNodes.isEmpty { for node in self.textField.tokenNodes.values { node.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } } var hasText = false if !(self.textField.text ?? "").isEmpty, let snapshotView = self.textField.snapshotView(afterScreenUpdates: false) { hasText = true snapshotView.frame = self.textField.frame self.textField.superview?.addSubview(snapshotView) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in snapshotView.removeFromSuperview() }) snapshotView.layer.animatePosition(from: .zero, to: CGPoint(x: targetLabelNodeFrame.minX - textFieldFrame.minX, y: 0.0), duration: duration, timingFunction: timingFunction, removeOnCompletion: false, additive: true) self.textField.placeholderLabel.alpha = 0.0 } self.textField.prefixString = nil self.textField.text = "" self.textField.layoutSubviews() var backgroundCompleted = false var separatorCompleted = false var textBackgroundCompleted = false let intermediateCompletion: () -> Void = { [weak node, weak self] in if backgroundCompleted && separatorCompleted && textBackgroundCompleted { completion() node?.isHidden = false self?.textField.isUserInteractionEnabled = true } } let targetBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.size.width, height: max(0.0, targetTextBackgroundFrame.maxY + 8.0))) if let toBackgroundColor = node.backgroundColor, let fromBackgroundColor = self.backgroundNode.backgroundColor { self.backgroundNode.layer.animate(from: fromBackgroundColor.cgColor, to: toBackgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration * 0.5, removeOnCompletion: false) } else { self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration / 2.0, removeOnCompletion: false) } self.backgroundNode.layer.animateFrame(from: self.backgroundNode.frame, to: targetBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in backgroundCompleted = true intermediateCompletion() }) let targetSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, targetTextBackgroundFrame.maxY + 8.0)), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel)) self.separatorNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration / 2.0, removeOnCompletion: false) self.separatorNode.layer.animateFrame(from: self.separatorNode.frame, to: targetSeparatorFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in separatorCompleted = true intermediateCompletion() }) self.textBackgroundNode.isHidden = true /*if let accessoryComponentView = node.accessoryComponentView { let tempContainer = UIView() let accessorySize = accessoryComponentView.bounds.size tempContainer.frame = CGRect(origin: CGPoint(x: self.textBackgroundNode.frame.maxX - accessorySize.width - 4.0, y: floor((self.textBackgroundNode.frame.minY + self.textBackgroundNode.frame.height - accessorySize.height) / 2.0)), size: accessorySize) let targetTempContainerFrame = CGRect(origin: CGPoint(x: targetTextBackgroundFrame.maxX - accessorySize.width - 4.0, y: floor((targetTextBackgroundFrame.minY + 8.0 + targetTextBackgroundFrame.height - accessorySize.height) / 2.0)), size: accessorySize) tempContainer.layer.animateFrame(from: tempContainer.frame, to: targetTempContainerFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) accessoryComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) tempContainer.addSubview(accessoryComponentView) self.view.addSubview(tempContainer) }*/ self.textBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { [weak node] _ in textBackgroundCompleted = true intermediateCompletion() if let node = node, let accessoryComponentView = node.accessoryComponentView { //accessoryComponentContainer.addSubview(accessoryComponentView) accessoryComponentView.layer.animateAlpha(from: 0.0, to: accessoryComponentView.alpha, duration: 0.2) } }) let transitionBackgroundNode = ASDisplayNode() transitionBackgroundNode.isLayerBacked = true transitionBackgroundNode.displaysAsynchronously = false transitionBackgroundNode.backgroundColor = node.backgroundView.backgroundColor transitionBackgroundNode.cornerRadius = node.backgroundView.layer.cornerRadius self.insertSubnode(transitionBackgroundNode, aboveSubnode: self.textBackgroundNode) transitionBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) if targetTextBackgroundFrame.height.isZero { self.iconNode.layer.animateAlpha(from: self.iconNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) self.textField.layer.animateAlpha(from: self.textField.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) } else if let snapshot = node.labelNode.layer.snapshotContentTree() { snapshot.frame = CGRect(origin: self.textField.placeholderLabel.frame.origin.offsetBy(dx: 0.0, dy: UIScreenPixel), size: node.labelNode.frame.size) self.textField.layer.addSublayer(snapshot) snapshot.animateAlpha(from: 0.0, to: 1.0, duration: duration * 2.0 / 3.0, timingFunction: CAMediaTimingFunctionName.linear.rawValue) if !hasText { self.textField.placeholderLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false) } } let iconFrame = self.iconNode.frame let targetIconFrame = CGRect(origin: node.iconNode.frame.offsetBy(dx: targetTextBackgroundFrame.origin.x, dy: targetTextBackgroundFrame.origin.y).origin, size: iconFrame.size) self.iconNode.image = node.iconNode.image self.iconNode.layer.animateFrame(from: self.iconNode.frame, to: targetIconFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) let cancelButtonFrame = self.cancelButton.frame self.cancelButton.layer.animatePosition(from: self.cancelButton.layer.position, to: CGPoint(x: self.bounds.size.width + cancelButtonFrame.size.width / 2.0, y: targetTextBackgroundFrame.midY), duration: duration, timingFunction: timingFunction, removeOnCompletion: false)*/ if let takenSearchPlaceholderContentView = self.takenSearchPlaceholderContentView { let transition = ComponentTransition(transition) let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) let sourceFrame = node.view.convert(node.bounds, to: self.view) takenSearchPlaceholderContentView.update(size: sourceFrame.size, isActive: false, transition: transition.containedViewLayoutTransition) takenSearchPlaceholderContentView.updatePlaceholderVisibility(isVisible: true) takenSearchPlaceholderContentView.updateSearchIconVisibility(isVisible: true) transition.setFrame(view: takenSearchPlaceholderContentView, frame: sourceFrame, completion: { [weak node] _ in node?.putBackContents() completion() }) let textBackgroundHeight: CGFloat if case .inlineNavigation = self.fieldStyle { textBackgroundHeight = sourceFrame.height } else { textBackgroundHeight = self.fieldStyle.height } let padding = self.fieldStyle.padding var textBackgroundFrame = CGRect(origin: CGPoint(x: sourceFrame.minX + padding, y: sourceFrame.minY), size: CGSize(width: sourceFrame.width - padding, height: textBackgroundHeight)) if case .glass = self.fieldStyle { textBackgroundFrame.size.width -= 8.0 } else { textBackgroundFrame.size.width -= padding } var textFrame = CGRect(origin: CGPoint(x: 0.0, y: textBackgroundFrame.minY), size: CGSize(width: max(1.0, textBackgroundFrame.size.width - 24.0 - 27.0), height: textBackgroundFrame.size.height)) if case .inlineNavigation = self.fieldStyle { textFrame.size.width = sourceFrame.width - 27.0 textBackgroundFrame.size.width = sourceFrame.width } else { textFrame.origin.x = textBackgroundFrame.minX + 24.0 } transition.setFrame(view: self.textField, frame: textFrame) //alphaTransition.setAlpha(view: self.textField, alpha: 0.0) self.textField.isHidden = true let clearSize = self.clearButton.bounds.size alphaTransition.setAlpha(view: self.clearButton.view, alpha: 0.0) transition.setFrame(view: self.clearButton.view, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.maxX - 6.0 - clearSize.width, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - clearSize.height) / 2.0)), size: clearSize)) } } public func textFieldDidBeginEditing(_ textField: UITextField) { self.focusUpdated?(true) } public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if let _ = self.textField.selectedTokenIndex { if !string.isEmpty { self.textField.selectedTokenIndex = nil } if string.range(of: " ") != nil { return false } } if string.range(of: "\n") != nil { return false } return true } public func textFieldShouldReturn(_ textField: UITextField) -> Bool { self.textField.resignFirstResponder() if let textReturned = self.textReturned { textReturned(textField.text ?? "") } return false } @objc private func textFieldDidChange(_ textField: UITextField) { self.updateIsEmpty() if let textUpdated = self.textUpdated { textUpdated(textField.text ?? "", textField.textInputMode?.primaryLanguage) } } public func textFieldDidEndEditing(_ textField: UITextField) { self.focusUpdated?(false) self.textField.selectedTokenIndex = nil } public func selectAll() { if !self.textField.isFirstResponder { let _ = self.textField.becomeFirstResponder() } self.textField.selectAll(nil) } public func selectLastToken() { if !self.textField.tokens.isEmpty { self.textField.selectedTokenIndex = self.textField.tokens.count - 1 if !self.textField.isFirstResponder { let _ = self.textField.becomeFirstResponder() } } } private func updateIsEmpty(animated: Bool = false) { var tokensEmpty = true for token in self.tokens { if !token.permanent { tokensEmpty = false } } let textIsEmpty = (self.textField.text?.isEmpty ?? true) let isEmpty = textIsEmpty && tokensEmpty let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.3, curve: .spring) : .immediate let placeholderTransition = !isEmpty ? .immediate : transition placeholderTransition.updateAlpha(node: self.textField.placeholderLabel, alpha: isEmpty ? 1.0 : 0.0) if let takenSearchPlaceholderContentView = self.takenSearchPlaceholderContentView { takenSearchPlaceholderContentView.updatePlaceholderVisibility(isVisible: isEmpty) } self.inlineSearchPlaceholderContentsView?.updatePlaceholderVisibility(isVisible: isEmpty) let clearIsHidden = (textIsEmpty && tokensEmpty) && self.prefixString == nil transition.updateAlpha(node: self.clearButton.imageNode, alpha: clearIsHidden ? 0.0 : 1.0) transition.updateTransformScale(node: self.clearButton, scale: clearIsHidden ? 0.2 : 1.0) self.clearButton.isUserInteractionEnabled = !clearIsHidden } @objc private func cancelPressed() { self.cancel?() } @objc private func clearPressed() { if (self.textField.text?.isEmpty ?? true) { if self.prefixString != nil { self.clearPrefix?() } if !self.tokens.isEmpty { self.clearTokens?() } } else { self.textField.text = "" self.textFieldDidChange(self.textField) } } }