Files
Swiftgram/submodules/BrowserUI/Sources/BrowserAddressBarComponent.swift
2025-12-29 10:05:51 +04:00

522 lines
22 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import BundleIconComponent
import MultilineTextComponent
import UrlEscaping
import GlassBackgroundComponent
import GlassBarButtonComponent
import EdgeEffect
final class AddressBarContentComponent: Component {
public typealias EnvironmentType = BrowserNavigationBarEnvironment
let theme: PresentationTheme
let strings: PresentationStrings
let metrics: LayoutMetrics
let url: String
let isSecure: Bool
let isExpanded: Bool
let readingProgress: CGFloat
let loadingProgress: Double?
let performAction: ActionSlot<BrowserScreen.Action>
init(
theme: PresentationTheme,
strings: PresentationStrings,
metrics: LayoutMetrics,
url: String,
isSecure: Bool,
isExpanded: Bool,
readingProgress: CGFloat,
loadingProgress: Double?,
performAction: ActionSlot<BrowserScreen.Action>
) {
self.theme = theme
self.strings = strings
self.metrics = metrics
self.url = url
self.isSecure = isSecure
self.isExpanded = isExpanded
self.readingProgress = readingProgress
self.loadingProgress = loadingProgress
self.performAction = performAction
}
static func ==(lhs: AddressBarContentComponent, rhs: AddressBarContentComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.metrics != rhs.metrics {
return false
}
if lhs.url != rhs.url {
return false
}
if lhs.isSecure != rhs.isSecure {
return false
}
if lhs.isExpanded != rhs.isExpanded {
return false
}
if lhs.readingProgress != rhs.readingProgress {
return false
}
if lhs.loadingProgress != rhs.loadingProgress {
return false
}
return true
}
final class View: UIView, UITextFieldDelegate {
private final class TextField: UITextField {
override func textRect(forBounds bounds: CGRect) -> CGRect {
return bounds.integral
}
override var canBecomeFirstResponder: Bool {
var canBecomeFirstResponder = super.canBecomeFirstResponder
if !canBecomeFirstResponder && self.alpha.isZero {
canBecomeFirstResponder = true
}
return canBecomeFirstResponder
}
}
private struct Params: Equatable {
var theme: PresentationTheme
var strings: PresentationStrings
var size: CGSize
var isActive: Bool
var title: String
var isSecure: Bool
var collapseFraction: CGFloat
var isTablet: Bool
var readingProgress: CGFloat
var loadingProgress: Double?
static func ==(lhs: Params, rhs: Params) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.size != rhs.size {
return false
}
if lhs.isActive != rhs.isActive {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.isSecure != rhs.isSecure {
return false
}
if lhs.collapseFraction != rhs.collapseFraction {
return false
}
if lhs.isTablet != rhs.isTablet {
return false
}
if lhs.readingProgress != rhs.readingProgress {
return false
}
if lhs.loadingProgress != rhs.loadingProgress {
return false
}
return true
}
}
private let activated: (Bool) -> Void = { _ in }
private let deactivated: (Bool) -> Void = { _ in }
private let backgroundView: GlassBackgroundView
private let clearIconView: UIImageView
private let clearIconButton: HighlightTrackingButton
private let cancelButton = ComponentView<Empty>()
private var placeholderContent = ComponentView<Empty>()
private var titleContent = ComponentView<Empty>()
private let clippingView = UIView()
private var loadingProgress = ComponentView<Empty>()
private var readingProgressView = UIView()
private var textFrame: CGRect?
private var textField: TextField?
private var tapRecognizer: UITapGestureRecognizer?
private var params: Params?
private var component: AddressBarContentComponent?
public var wantsDisplayBelowKeyboard: Bool {
return self.textField != nil
}
init() {
self.backgroundView = GlassBackgroundView()
self.clearIconView = UIImageView()
self.clearIconButton = HighlightableButton()
self.clearIconView.isHidden = false
self.clearIconButton.isHidden = false
super.init(frame: CGRect())
self.clippingView.clipsToBounds = true
self.addSubview(self.backgroundView)
self.backgroundView.contentView.addSubview(self.clippingView)
self.clippingView.addSubview(self.readingProgressView)
self.backgroundView.contentView.addSubview(self.clearIconView)
self.backgroundView.contentView.addSubview(self.clearIconButton)
self.clipsToBounds = true
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.tapRecognizer = tapRecognizer
self.addGestureRecognizer(tapRecognizer)
self.clearIconButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.clearIconView.layer.removeAnimation(forKey: "opacity")
strongSelf.clearIconView.alpha = 0.4
} else {
strongSelf.clearIconView.alpha = 1.0
strongSelf.clearIconView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.clearIconButton.addTarget(self, action: #selector(self.clearPressed), for: .touchUpInside)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state, let component = self.component, !component.isExpanded {
component.performAction.invoke(.openAddressBar)
}
}
private func activateTextInput() {
self.activated(true)
if let textField = self.textField {
textField.becomeFirstResponder()
Queue.mainQueue().after(0.3, {
textField.selectAll(nil)
})
}
}
private func deactivateTextInput() {
self.textField?.endEditing(true)
}
@objc private func cancelPressed() {
self.deactivated(self.textField?.isFirstResponder ?? false)
self.component?.performAction.invoke(.closeAddressBar)
}
@objc private func clearPressed() {
guard let textField = self.textField else {
return
}
textField.text = ""
self.textFieldChanged(textField)
}
public func textFieldDidBeginEditing(_ textField: UITextField) {
}
public func textFieldDidEndEditing(_ textField: UITextField) {
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if let component = self.component {
let finalUrl = explicitUrl(textField.text ?? "")
component.performAction.invoke(.navigateTo(finalUrl, true))
}
textField.endEditing(true)
return false
}
@objc private func textFieldChanged(_ textField: UITextField) {
let text = textField.text ?? ""
self.clearIconView.isHidden = text.isEmpty
self.clearIconButton.isHidden = text.isEmpty
self.placeholderContent.view?.isHidden = !text.isEmpty
if let params = self.params {
self.update(theme: params.theme, strings: params.strings, size: params.size, isActive: params.isActive, title: params.title, isSecure: params.isSecure, collapseFraction: params.collapseFraction, isTablet: params.isTablet, readingProgress: params.readingProgress, loadingProgress: params.loadingProgress, transition: .immediate)
}
}
func update(
component: AddressBarContentComponent,
availableSize: CGSize,
environment: Environment<BrowserNavigationBarEnvironment>,
transition: ComponentTransition
) -> CGSize {
let collapseFraction = environment[BrowserNavigationBarEnvironment.self].fraction
let wasExpanded = self.component?.isExpanded ?? false
self.component = component
if !wasExpanded && component.isExpanded {
self.activateTextInput()
}
if wasExpanded && !component.isExpanded {
self.deactivateTextInput()
}
let isActive = self.textField?.isFirstResponder ?? false
let title = getDisplayUrl(component.url, hostOnly: true)
self.update(
theme: component.theme,
strings: component.strings,
size: availableSize,
isActive: isActive,
title: title.lowercased(),
isSecure: component.isSecure,
collapseFraction: collapseFraction,
isTablet: component.metrics.isTablet,
readingProgress: component.readingProgress,
loadingProgress: component.loadingProgress,
transition: transition
)
return availableSize
}
public func update(
theme: PresentationTheme,
strings: PresentationStrings,
size: CGSize,
isActive: Bool,
title: String,
isSecure: Bool,
collapseFraction: CGFloat,
isTablet: Bool,
readingProgress: CGFloat,
loadingProgress: Double?,
transition: ComponentTransition
) {
let params = Params(
theme: theme,
strings: strings,
size: size,
isActive: isActive,
title: title,
isSecure: isSecure,
collapseFraction: collapseFraction,
isTablet: isTablet,
readingProgress: readingProgress,
loadingProgress: loadingProgress
)
if self.params == params {
return
}
let isActiveWithText = self.component?.isExpanded ?? false
if self.params?.theme !== theme {
self.clearIconView.image = generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: .white)?.withRenderingMode(.alwaysTemplate)
self.clearIconView.tintColor = theme.rootController.navigationSearchBar.inputClearButtonColor
}
self.params = params
let sideInset: CGFloat = 10.0
let inputHeight: CGFloat = 44.0
let topInset: CGFloat = (size.height - inputHeight) / 2.0
let titleSize = self.titleContent.update(
transition: transition,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(string: title, font: Font.regular(17.0), textColor: theme.rootController.navigationSearchBar.inputTextColor)),
horizontalAlignment: .center,
truncationType: .end,
maximumNumberOfLines: 1
)
),
environment: {},
containerSize: CGSize(width: size.width - 36.0, height: size.height)
)
let expandedBackgroundWidth = size.width - 14.0 * 2.0
let collapsedBackgroundWidth = titleSize.width + 32.0
var backgroundSize = CGSize(width: expandedBackgroundWidth * (1.0 - collapseFraction) + collapsedBackgroundWidth * collapseFraction, height: 44.0)
let cancelButtonSpacing: CGFloat = 8.0
let cancelSize = self.cancelButton.update(
transition: transition,
component: AnyComponent(
GlassBarButtonComponent(
size: CGSize(width: 44.0, height: 44.0),
backgroundColor: nil,
isDark: theme.overallDarkAppearance,
state: .glass,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(name: "Navigation/Close", tintColor: theme.chat.inputPanel.panelControlColor)
)),
action: { [weak self] _ in
self?.cancelPressed()
}
)
),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
if isActiveWithText && !isTablet {
backgroundSize.width -= cancelSize.width + cancelButtonSpacing + 4.0
}
self.backgroundView.update(size: backgroundSize, cornerRadius: backgroundSize.height * 0.5, isDark: theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: UIColor(white: theme.overallDarkAppearance ? 0.0 : 1.0, alpha: 0.6)), isInteractive: true, transition: transition)
var backgroundFrame = CGRect(origin: CGPoint(x: floor((size.width - backgroundSize.width) / 2.0), y: topInset), size: backgroundSize)
if isActiveWithText && !isTablet {
backgroundFrame.origin.x = 16.0
}
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
transition.setFrame(view: self.clippingView, frame: CGRect(origin: .zero, size: backgroundFrame.size))
transition.setCornerRadius(layer: self.clippingView.layer, cornerRadius: backgroundFrame.size.height * 0.5)
let textX: CGFloat = sideInset
let textFrame = CGRect(origin: CGPoint(x: textX, y: 0.0), size: CGSize(width: backgroundFrame.maxX - textX, height: backgroundFrame.height))
let placeholderSize = self.placeholderContent.update(
transition: transition,
component: AnyComponent(
Text(text: strings.WebBrowser_AddressPlaceholder, font: Font.regular(17.0), color: theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
),
environment: {},
containerSize: size
)
if let placeholderContentView = self.placeholderContent.view {
if placeholderContentView.superview == nil {
placeholderContentView.alpha = 0.0
placeholderContentView.isHidden = true
self.backgroundView.contentView.addSubview(placeholderContentView)
}
let placeholderContentFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: backgroundFrame.size.height / 2.0 - placeholderSize.height / 2.0), size: placeholderSize)
transition.setFrame(view: placeholderContentView, frame: placeholderContentFrame)
transition.setAlpha(view: placeholderContentView, alpha: isActiveWithText ? 1.0 : 0.0)
}
let titleContentFrame = CGRect(origin: CGPoint(x: isActiveWithText ? textFrame.minX : backgroundFrame.width / 2.0 - titleSize.width / 2.0, y: backgroundFrame.height / 2.0 - titleSize.height / 2.0), size: titleSize)
if let titleContentView = self.titleContent.view {
if titleContentView.superview == nil {
self.backgroundView.contentView.addSubview(titleContentView)
}
transition.setPosition(view: titleContentView, position: titleContentFrame.center)
titleContentView.bounds = CGRect(origin: .zero, size: titleContentFrame.size)
transition.setAlpha(view: titleContentView, alpha: isActiveWithText ? 0.0 : 1.0)
}
if let image = self.clearIconView.image {
let iconFrame = CGRect(origin: CGPoint(x: backgroundFrame.width - image.size.width - 4.0, y: floor((backgroundFrame.height - image.size.height) / 2.0)), size: image.size)
transition.setFrame(view: self.clearIconView, frame: iconFrame)
transition.setFrame(view: self.clearIconButton, frame: iconFrame.insetBy(dx: -8.0, dy: -10.0))
transition.setAlpha(view: self.clearIconView, alpha: isActiveWithText ? 1.0 : 0.0)
self.clearIconButton.isUserInteractionEnabled = isActiveWithText
}
if let cancelButtonView = self.cancelButton.view {
if cancelButtonView.superview == nil {
self.addSubview(cancelButtonView)
}
transition.setFrame(view: cancelButtonView, frame: CGRect(origin: CGPoint(x: backgroundFrame.maxX + cancelButtonSpacing, y: floor((size.height - cancelSize.height) / 2.0)), size: cancelSize))
transition.setAlpha(view: cancelButtonView, alpha: isActiveWithText && !isTablet ? 1.0 : 0.0)
}
let textFieldFrame = CGRect(origin: CGPoint(x: sideInset, y: 0.0), size: CGSize(width: backgroundFrame.width - sideInset, height: backgroundFrame.height))
let textField: TextField
if let current = self.textField {
textField = current
} else {
textField = TextField(frame: textFieldFrame)
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
textField.keyboardType = .URL
textField.returnKeyType = .go
self.backgroundView.contentView.insertSubview(textField, belowSubview: self.clearIconView)
self.textField = textField
textField.delegate = self
textField.addTarget(self, action: #selector(self.textFieldChanged(_:)), for: .editingChanged)
}
let address = getDisplayUrl(self.component?.url ?? "", trim: false)
if textField.text != address {
textField.text = address
self.clearIconView.isHidden = address.isEmpty
self.clearIconButton.isHidden = address.isEmpty
self.placeholderContent.view?.isHidden = !address.isEmpty
}
textField.textColor = theme.rootController.navigationSearchBar.inputTextColor
transition.setFrame(view: textField, frame: CGRect(origin: CGPoint(x: sideInset, y: -UIScreenPixel), size: CGSize(width: backgroundFrame.width - sideInset - 32.0, height: backgroundFrame.height)))
transition.setAlpha(view: textField, alpha: isActiveWithText ? 1.0 : 0.0)
textField.isUserInteractionEnabled = isActiveWithText
let loadingProgressInset: CGFloat = 12.0
let loadingProgressSize = CGSize(width: backgroundSize.width - loadingProgressInset * 2.0, height: 2.0)
let _ = self.loadingProgress.update(
transition: transition,
component: AnyComponent(LoadingProgressComponent(
color: theme.rootController.navigationBar.accentTextColor,
height: loadingProgressSize.height,
value: params.loadingProgress ?? 0.0
)),
environment: {},
containerSize: loadingProgressSize
)
if let loadingProgressView = self.loadingProgress.view {
if loadingProgressView.superview == nil {
self.clippingView.addSubview(loadingProgressView)
}
transition.setFrame(view: loadingProgressView, frame: CGRect(origin: CGPoint(x: loadingProgressInset, y: backgroundSize.height - loadingProgressSize.height), size: loadingProgressSize))
transition.setAlpha(view: loadingProgressView, alpha: isActiveWithText ? 0.0 : 1.0)
}
self.readingProgressView.backgroundColor = theme.rootController.navigationBar.primaryTextColor.withMultipliedAlpha(0.07)
self.readingProgressView.frame = CGRect(origin: .zero, size: CGSize(width: backgroundSize.width * params.readingProgress, height: backgroundSize.height))
transition.setAlpha(view: self.readingProgressView, alpha: isActiveWithText ? 0.0 : 1.0)
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<BrowserNavigationBarEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition)
}
}