mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1175 lines
52 KiB
Swift
1175 lines
52 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import SwiftSignalKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import TelegramPresentationData
|
|
import ActivityIndicator
|
|
import AppBundle
|
|
|
|
private func generateLoupeIcon(color: UIColor) -> UIImage? {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Loupe"), 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 icon: UIImage?
|
|
public let title: String
|
|
public let style: Style?
|
|
|
|
public init(id: AnyHashable, icon: UIImage?, title: String, style: Style? = nil) {
|
|
self.id = id
|
|
self.icon = icon
|
|
self.title = title
|
|
self.style = style
|
|
}
|
|
}
|
|
|
|
private final class TokenNode: ASDisplayNode {
|
|
var theme: SearchBarNodeTheme
|
|
let token: SearchBarToken
|
|
let containerNode: ASDisplayNode
|
|
let iconNode: ASImageNode
|
|
let titleNode: ASTextNode
|
|
let backgroundNode: ASImageNode
|
|
|
|
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
|
|
|
|
super.init()
|
|
|
|
self.clipsToBounds = true
|
|
self.addSubnode(self.containerNode)
|
|
self.containerNode.addSubnode(self.backgroundNode)
|
|
|
|
let backgroundColor = token.style?.backgroundColor ?? theme.inputIcon
|
|
let strokeColor = token.style?.strokeColor ?? backgroundColor
|
|
self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 8.0, color: backgroundColor, strokeColor: strokeColor, strokeWidth: UIScreenPixel, backgroundColor: nil)
|
|
|
|
let foregroundColor = 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(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() {
|
|
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)
|
|
|
|
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 = isSelected ? self.theme.accent : (token.style?.backgroundColor ?? self.theme.inputIcon)
|
|
let strokeColor = isSelected ? backgroundColor : (token.style?.strokeColor ?? backgroundColor)
|
|
self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 8.0, color: backgroundColor, strokeColor: strokeColor, strokeWidth: UIScreenPixel, backgroundColor: nil)
|
|
|
|
var foregroundColor = isSelected ? .white : (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(17.0), textColor: foregroundColor)
|
|
}
|
|
}
|
|
|
|
func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
|
|
let height: CGFloat = 24.0
|
|
|
|
var leftInset: CGFloat = 3.0
|
|
if let icon = self.iconNode.image {
|
|
leftInset += 1.0
|
|
transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: leftInset, y: floor((height - icon.size.height) / 2.0)), size: icon.size))
|
|
leftInset += icon.size.width + 3.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
|
|
}
|
|
|
|
let 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
|
|
|
|
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 && (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
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private 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) {
|
|
self.theme = theme
|
|
|
|
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())
|
|
|
|
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 placeholderOffset: CGFloat = 0.0
|
|
if #available(iOS 14.0, *) {
|
|
placeholderOffset = 1.0
|
|
} else {
|
|
}
|
|
|
|
let textRect = self.textRect(forBounds: bounds)
|
|
let labelSize = self.placeholderLabel.updateLayout(textRect.size)
|
|
self.placeholderLabel.frame = CGRect(origin: CGPoint(x: textRect.minX, y: textRect.minY + textOffset + placeholderOffset), 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: prefixBounds.minY + textOffset + placeholderOffset), 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<UITouch>, 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, hasSeparator: Bool = true) {
|
|
self.background = theme.rootController.navigationBar.backgroundColor
|
|
self.separator = hasSeparator ? theme.rootController.navigationBar.separatorColor : theme.rootController.navigationBar.backgroundColor
|
|
self.inputFill = theme.rootController.navigationSearchBar.inputFillColor
|
|
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
|
|
|
|
var font: UIFont {
|
|
switch self {
|
|
case .modern:
|
|
return Font.regular(17.0)
|
|
case .legacy:
|
|
return Font.regular(14.0)
|
|
}
|
|
}
|
|
|
|
var cornerDiameter: CGFloat {
|
|
switch self {
|
|
case .modern:
|
|
return 21.0
|
|
case .legacy:
|
|
return 14.0
|
|
}
|
|
}
|
|
|
|
var height: CGFloat {
|
|
switch self {
|
|
case .modern:
|
|
return 36.0
|
|
case .legacy:
|
|
return 28.0
|
|
}
|
|
}
|
|
|
|
var padding: CGFloat {
|
|
switch self {
|
|
case .modern:
|
|
return 10.0
|
|
case .legacy:
|
|
return 8.0
|
|
}
|
|
}
|
|
}
|
|
|
|
public class SearchBarNode: ASDisplayNode, UITextFieldDelegate {
|
|
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 tokensUpdated: (([SearchBarToken]) -> Void)?
|
|
|
|
private let backgroundNode: ASDisplayNode
|
|
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
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var validLayout: (CGSize, CGFloat, CGFloat)?
|
|
|
|
private let fieldStyle: SearchBarStyle
|
|
private let forceSeparator: Bool
|
|
private var theme: SearchBarNodeTheme?
|
|
private var strings: PresentationStrings?
|
|
private let cancelText: String?
|
|
|
|
public init(theme: SearchBarNodeTheme, strings: PresentationStrings, fieldStyle: SearchBarStyle = .legacy, forceSeparator: Bool = false, cancelText: String? = nil) {
|
|
self.fieldStyle = fieldStyle
|
|
self.forceSeparator = forceSeparator
|
|
self.cancelText = cancelText
|
|
|
|
self.backgroundNode = ASDisplayNode()
|
|
self.backgroundNode.isLayerBacked = true
|
|
|
|
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)
|
|
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()
|
|
|
|
self.addSubnode(self.backgroundNode)
|
|
self.addSubnode(self.separatorNode)
|
|
|
|
self.addSubnode(self.textBackgroundNode)
|
|
self.view.addSubview(self.textField)
|
|
self.addSubnode(self.iconNode)
|
|
self.addSubnode(self.clearButton)
|
|
self.addSubnode(self.cancelButton)
|
|
|
|
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 {
|
|
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, strings: strings)
|
|
self.updateIsEmpty(animated: false)
|
|
}
|
|
|
|
public func updateThemeAndStrings(theme: SearchBarNodeTheme, strings: PresentationStrings) {
|
|
if self.theme != theme || self.strings !== strings {
|
|
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.backgroundColor = theme.background
|
|
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: [])
|
|
self.iconNode.image = generateLoupeIcon(color: theme.inputIcon)
|
|
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.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
|
|
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 verticalOffset: CGFloat = boundingSize.height - 82.0
|
|
|
|
let contentFrame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: boundingSize.width - leftInset - rightInset, height: boundingSize.height))
|
|
|
|
let textBackgroundHeight = self.fieldStyle.height
|
|
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
|
|
let textBackgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX + padding, y: verticalOffset + textBackgroundHeight), size: CGSize(width: contentFrame.width - padding * 2.0 - (self.hasCancelButton ? cancelButtonSize.width + 11.0 : 0.0), height: textBackgroundHeight))
|
|
transition.updateFrame(node: self.textBackgroundNode, frame: textBackgroundFrame)
|
|
|
|
let textFrame = CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 24.0, y: textBackgroundFrame.minY), size: CGSize(width: max(1.0, textBackgroundFrame.size.width - 24.0 - 20.0), height: textBackgroundFrame.size.height))
|
|
|
|
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
|
|
}
|
|
|
|
@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) {
|
|
let initialTextBackgroundFrame = node.convert(node.backgroundNode.frame, to: self)
|
|
|
|
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.backgroundNode.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)
|
|
|
|
let textFieldFrame = self.textField.frame
|
|
let initialLabelNodeFrame = CGRect(origin: node.labelNode.frame.offsetBy(dx: initialTextBackgroundFrame.origin.x - 7.0, 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) {
|
|
let targetTextBackgroundFrame = node.convert(node.backgroundNode.frame, to: self)
|
|
|
|
let duration: Double = transition.isAnimated ? 0.5 : 0.0
|
|
let timingFunction = kCAMediaTimingFunctionSpring
|
|
|
|
node.isHidden = true
|
|
self.clearButton.isHidden = true
|
|
self.activityIndicator?.isHidden = true
|
|
self.iconNode.isHidden = false
|
|
self.textField.prefixString = nil
|
|
self.textField.text = ""
|
|
self.textField.layoutSubviews()
|
|
|
|
var backgroundCompleted = false
|
|
var separatorCompleted = false
|
|
var textBackgroundCompleted = false
|
|
let intermediateCompletion: () -> Void = { [weak node] in
|
|
if backgroundCompleted && separatorCompleted && textBackgroundCompleted {
|
|
completion()
|
|
node?.isHidden = false
|
|
}
|
|
}
|
|
|
|
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.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in
|
|
textBackgroundCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
|
|
let transitionBackgroundNode = ASDisplayNode()
|
|
transitionBackgroundNode.isLayerBacked = true
|
|
transitionBackgroundNode.displaysAsynchronously = false
|
|
transitionBackgroundNode.backgroundColor = node.backgroundNode.backgroundColor
|
|
transitionBackgroundNode.cornerRadius = node.backgroundNode.cornerRadius
|
|
self.insertSubnode(transitionBackgroundNode, aboveSubnode: self.textBackgroundNode)
|
|
transitionBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration / 2.0, removeOnCompletion: false)
|
|
transitionBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
|
|
|
|
let textFieldFrame = self.textField.frame
|
|
let targetLabelNodeFrame = CGRect(origin: CGPoint(x: node.labelNode.frame.minX + targetTextBackgroundFrame.origin.x - 7.0, y: targetTextBackgroundFrame.minY + floorToScreenPixels((targetTextBackgroundFrame.size.height - textFieldFrame.size.height) / 2.0) - UIScreenPixel), size: textFieldFrame.size)
|
|
self.textField.layer.animateFrame(from: self.textField.frame, to: targetLabelNodeFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
|
|
if #available(iOSApplicationExtension 10.0, iOS 10.0, *) {
|
|
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)
|
|
self.textField.placeholderLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false)
|
|
}
|
|
} else if let cachedLayout = node.labelNode.cachedLayout {
|
|
let labelNode = TextNode()
|
|
labelNode.isOpaque = false
|
|
labelNode.isUserInteractionEnabled = false
|
|
let labelLayout = TextNode.asyncLayout(labelNode)
|
|
let (labelLayoutResult, labelApply) = labelLayout(TextNodeLayoutArguments(attributedString: self.placeholderString, backgroundColor: .clear, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: cachedLayout.size, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
|
|
let _ = labelApply()
|
|
|
|
self.textField.addSubnode(labelNode)
|
|
labelNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 2.0 / 3.0, timingFunction: CAMediaTimingFunctionName.linear.rawValue)
|
|
labelNode.frame = CGRect(origin: self.textField.placeholderLabel.frame.origin.offsetBy(dx: 0.0, dy: UIScreenPixel), size: labelLayoutResult.size)
|
|
self.textField.placeholderLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { _ in
|
|
labelNode.removeFromSupernode()
|
|
})
|
|
}
|
|
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)
|
|
}
|
|
|
|
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.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) {
|
|
let isEmpty = (self.textField.text?.isEmpty ?? true) && self.tokens.isEmpty
|
|
|
|
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)
|
|
|
|
let clearIsHidden = isEmpty && 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)
|
|
}
|
|
}
|
|
}
|