This commit is contained in:
Isaac
2025-12-02 17:01:07 +08:00
parent 2967648b2b
commit 765aa49f41
5 changed files with 378 additions and 16 deletions

View File

@@ -16,6 +16,7 @@ swift_library(
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/SwitchNode",
"//submodules/CheckNode",
"//submodules/Components/MultilineTextComponent",
],
visibility = [
"//visibility:public",

View File

@@ -0,0 +1,260 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
private final class ContextOptionComponent: Component {
let title: String
let color: UIColor
let isLast: Bool
let action: () -> Void
init(
title: String,
color: UIColor,
isLast: Bool,
action: @escaping () -> Void
) {
self.title = title
self.color = color
self.isLast = isLast
self.action = action
}
static func ==(lhs: ContextOptionComponent, rhs: ContextOptionComponent) -> Bool {
if lhs.title != rhs.title {
return false
}
if lhs.color != rhs.color {
return false
}
if lhs.isLast != rhs.isLast {
return false
}
return true
}
final class View: UIView {
let backgroundView: UIView
let title = ComponentView<Empty>()
var component: ContextOptionComponent?
override init(frame: CGRect) {
self.backgroundView = UIView()
super.init(frame: frame)
self.addSubview(self.backgroundView)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.component?.action()
}
}
func update(component: ContextOptionComponent, availableSize: CGSize, state: State, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let sideInset: CGFloat = 8.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0)
)
let size = CGSize(width: sideInset * 2.0 + titleSize.width, height: availableSize.height)
let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((size.height - titleSize.height) * 0.5)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.center)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
self.backgroundView.backgroundColor = component.color
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width + (component.isLast ? 1000.0 : 0.0), height: size.height)))
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: State, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class ContentContainer: UIScrollView, UIScrollViewDelegate {
private var itemViews: [AnyHashable: ComponentView<Empty>] = [:]
private var ignoreScrollingEvents: Bool = false
private var draggingBeganInClosedState: Bool = false
private var contextOptions: [ListActionItemComponent.ContextOption] = []
private var optionsWidth: CGFloat = 0.0
private var revealedStateTapRecognizer: UITapGestureRecognizer?
override init(frame: CGRect) {
super.init(frame: frame)
let revealedStateTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))
self.revealedStateTapRecognizer = revealedStateTapRecognizer
revealedStateTapRecognizer.isEnabled = false
self.addGestureRecognizer(revealedStateTapRecognizer)
self.delaysContentTouches = false
self.canCancelContentTouches = true
self.clipsToBounds = false
self.contentInsetAdjustmentBehavior = .never
self.automaticallyAdjustsScrollIndicatorInsets = false
self.showsVerticalScrollIndicator = false
self.showsHorizontalScrollIndicator = false
self.alwaysBounceHorizontal = false
self.alwaysBounceVertical = false
self.scrollsToTop = false
self.delegate = self
self.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in
guard let self else {
return false
}
if self.contentOffset.x != 0.0 {
return true
}
return false
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func onTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
self.revealedStateTapRecognizer?.isEnabled = self.contentOffset.x > 0.0
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.draggingBeganInClosedState = self.contentOffset.x == 0.0
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee.x = self.contentOffset.x
if self.contentOffset.x >= self.optionsWidth + 30.0 {
self.contextOptions.last?.action()
} else {
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
if self.draggingBeganInClosedState {
if self.contentOffset.x > 20.0 {
self.setContentOffset(CGPoint(x: self.optionsWidth, y: 0.0), animated: true)
} else {
self.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true)
}
} else {
if self.contentOffset.x < self.optionsWidth - 20.0 {
self.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true)
} else {
self.setContentOffset(CGPoint(x: self.optionsWidth, y: 0.0), animated: true)
}
}
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let revealedStateTapRecognizer = self.revealedStateTapRecognizer, revealedStateTapRecognizer.isEnabled {
if self.bounds.contains(point), point.x < self.bounds.width {
return self
}
}
guard let result = super.hitTest(point, with: event) else {
return nil
}
return result
}
func update(size: CGSize, contextOptions: [ListActionItemComponent.ContextOption], transition: ComponentTransition) {
self.contextOptions = contextOptions
var validIds: [AnyHashable] = []
var optionsWidth: CGFloat = 0.0
for i in 0 ..< contextOptions.count {
let option = contextOptions[i]
validIds.append(option.id)
let itemView: ComponentView<Empty>
var itemTransition = transition
if let current = self.itemViews[option.id] {
itemView = current
} else {
itemTransition = itemTransition.withAnimation(.none)
itemView = ComponentView()
self.itemViews[option.id] = itemView
}
let itemSize = itemView.update(
transition: itemTransition,
component: AnyComponent(ContextOptionComponent(
title: option.title,
color: option.color,
isLast: i == contextOptions.count - 1,
action: option.action
)),
environment: {},
containerSize: CGSize(width: 10000.0, height: size.height)
)
let itemFrame = CGRect(origin: CGPoint(x: size.width + optionsWidth, y: 0.0), size: itemSize)
optionsWidth += itemSize.width
if let itemComponentView = itemView.view {
self.addSubview(itemComponentView)
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
}
}
var removedIds: [AnyHashable] = []
for (id, itemView) in self.itemViews {
if !validIds.contains(id) {
removedIds.append(id)
if let itemComponentView = itemView.view {
itemComponentView.removeFromSuperview()
}
}
}
for id in removedIds {
self.itemViews.removeValue(forKey: id)
}
self.optionsWidth = optionsWidth
let contentSize = CGSize(width: size.width + optionsWidth, height: size.height)
if self.contentSize != contentSize {
self.contentSize = contentSize
}
self.isScrollEnabled = optionsWidth != 0.0
}
}

View File

@@ -138,6 +138,33 @@ public final class ListActionItemComponent: Component {
case center
}
public final class ContextOption: Equatable {
public let id: AnyHashable
public let title: String
public let color: UIColor
public let action: () -> Void
public init(id: AnyHashable, title: String, color: UIColor, action: @escaping () -> Void) {
self.id = id
self.title = title
self.color = color
self.action = action
}
public static func ==(lhs: ContextOption, rhs: ContextOption) -> Bool {
if lhs.id != rhs.id {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.color != rhs.color {
return false
}
return true
}
}
public let theme: PresentationTheme
public let style: Style
public let background: AnyComponent<Empty>?
@@ -147,6 +174,7 @@ public final class ListActionItemComponent: Component {
public let leftIcon: LeftIcon?
public let icon: Icon?
public let accessory: Accessory?
public let contextOptions: [ContextOption]
public let action: ((UIView) -> Void)?
public let highlighting: Highlighting
public let updateIsHighlighted: ((UIView, Bool) -> Void)?
@@ -161,6 +189,7 @@ public final class ListActionItemComponent: Component {
leftIcon: LeftIcon? = nil,
icon: Icon? = nil,
accessory: Accessory? = .arrow,
contextOptions: [ContextOption] = [],
action: ((UIView) -> Void)?,
highlighting: Highlighting = .default,
updateIsHighlighted: ((UIView, Bool) -> Void)? = nil
@@ -174,6 +203,7 @@ public final class ListActionItemComponent: Component {
self.leftIcon = leftIcon
self.icon = icon
self.accessory = accessory
self.contextOptions = contextOptions
self.action = action
self.highlighting = highlighting
self.updateIsHighlighted = updateIsHighlighted
@@ -207,6 +237,9 @@ public final class ListActionItemComponent: Component {
if lhs.accessory != rhs.accessory {
return false
}
if lhs.contextOptions != rhs.contextOptions {
return false
}
if (lhs.action == nil) != (rhs.action == nil) {
return false
}
@@ -289,7 +322,9 @@ public final class ListActionItemComponent: Component {
}
}
public final class View: HighlightTrackingButton, ListSectionComponent.ChildView {
public final class View: UIView, ListSectionComponent.ChildView {
private let container: ContentContainer
private let button: HighlightTrackingButton
private var background: ComponentView<Empty>?
private let title = ComponentView<Empty>()
private var leftIcon: ComponentView<Empty>?
@@ -316,10 +351,16 @@ public final class ListActionItemComponent: Component {
public var separatorInset: CGFloat = 0.0
public override init(frame: CGRect) {
self.container = ContentContainer(frame: CGRect())
self.button = HighlightTrackingButton()
super.init(frame: CGRect())
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.internalHighligthedChanged = { [weak self] isHighlighted in
self.addSubview(self.container)
self.container.addSubview(self.button)
self.button.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.button.internalHighligthedChanged = { [weak self] isHighlighted in
guard let self, let component = self.component, component.action != nil else {
return
}
@@ -479,7 +520,7 @@ public final class ListActionItemComponent: Component {
let iconFrame = CGRect(origin: CGPoint(x: availableSize.width - contentRightInset - iconSize.width + iconOffset, y: floor((contentHeight - iconSize.height) * 0.5)), size: iconSize)
if let iconView = icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
self.button.addSubview(iconView)
transition.animateAlpha(view: iconView, from: 0.0, to: 1.0)
}
iconView.isUserInteractionEnabled = iconValue.allowUserInteraction
@@ -516,7 +557,7 @@ public final class ListActionItemComponent: Component {
animateIn = true
leftCheckView = CheckView()
self.leftCheckView = leftCheckView
self.addSubview(leftCheckView)
self.button.addSubview(leftCheckView)
leftCheckView.action = { [weak self] in
guard let self, let component = self.component else {
@@ -596,7 +637,7 @@ public final class ListActionItemComponent: Component {
if let leftIconView = leftIcon.view {
if leftIconView.superview == nil {
leftIconView.isUserInteractionEnabled = false
self.addSubview(leftIconView)
self.button.addSubview(leftIconView)
transition.animateAlpha(view: leftIconView, from: 0.0, to: 1.0)
}
leftIconTransition.setFrame(view: leftIconView, frame: leftIconFrame)
@@ -634,7 +675,7 @@ public final class ListActionItemComponent: Component {
arrowTransition = arrowTransition.withAnimation(.none)
arrowView = UIImageView(image: PresentationResourcesItemList.disclosureArrowImage(component.theme))
self.arrowView = arrowView
self.addSubview(arrowView)
self.button.addSubview(arrowView)
}
if let image = arrowView.image {
@@ -660,7 +701,7 @@ public final class ListActionItemComponent: Component {
arrowTransition = arrowTransition.withAnimation(.none)
arrowView = UIImageView(image: PresentationResourcesItemList.disclosureOptionArrowsImage(component.theme))
self.arrowView = arrowView
self.addSubview(arrowView)
self.button.addSubview(arrowView)
}
if let image = arrowView.image {
@@ -689,7 +730,7 @@ public final class ListActionItemComponent: Component {
switchNode = SwitchNode()
switchNode.setOn(toggle.isOn, animated: false)
self.switchNode = switchNode
self.addSubview(switchNode.view)
self.button.addSubview(switchNode.view)
switchNode.valueUpdated = { [weak self] value in
guard let self, let component = self.component else {
@@ -739,7 +780,7 @@ public final class ListActionItemComponent: Component {
switchNode.updateIsLocked(toggle.style == .lock)
switchNode.setOn(toggle.isOn, animated: false)
self.iconSwitchNode = switchNode
self.addSubview(switchNode.view)
self.button.addSubview(switchNode.view)
switchNode.valueUpdated = { [weak self] value in
guard let self, let component = self.component else {
@@ -792,7 +833,7 @@ public final class ListActionItemComponent: Component {
activityIndicatorView = UIActivityIndicatorView(style: .gray)
}
self.activityIndicatorView = activityIndicatorView
self.addSubview(activityIndicatorView)
self.button.addSubview(activityIndicatorView)
activityIndicatorView.sizeToFit()
}
@@ -818,7 +859,7 @@ public final class ListActionItemComponent: Component {
if let customAccessoryComponentView = customAccessoryView.view {
if customAccessoryComponentView.superview == nil {
customAccessoryComponentView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0)
self.addSubview(customAccessoryComponentView)
self.button.addSubview(customAccessoryComponentView)
}
customAccessoryComponentView.isUserInteractionEnabled = customAccessory.isInteractive
customAccessoryTransition.setPosition(view: customAccessoryComponentView, position: CGPoint(x: activityAccessoryFrame.maxX, y: activityAccessoryFrame.minY))
@@ -835,7 +876,7 @@ public final class ListActionItemComponent: Component {
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
self.button.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: titleFrame)
}
@@ -879,7 +920,12 @@ public final class ListActionItemComponent: Component {
}
}
return CGSize(width: availableSize.width, height: contentHeight)
let size = CGSize(width: availableSize.width, height: contentHeight)
self.container.update(size: size, contextOptions: component.contextOptions, transition: transition)
transition.setFrame(view: self.container, frame: CGRect(origin: CGPoint(), size: size))
transition.setFrame(view: self.button, frame: CGRect(origin: CGPoint(), size: size))
return size
}
}

View File

@@ -299,8 +299,15 @@ final class PasskeysScreenComponent: Component {
component: AnyComponent(PasskeysScreenListComponent(
context: component.context,
theme: environment.theme,
strings: environment.strings,
insets: UIEdgeInsets(top: environment.statusBarHeight, left: 0.0, bottom: environment.safeInsets.bottom, right: 0.0),
passkeys: passkeysData,
addPasskeyAction: { [weak self] in
guard let self else {
return
}
self.createPasskey()
},
deletePasskeyAction: { [weak self] id in
guard let self else {
return

View File

@@ -17,21 +17,27 @@ import EmojiStatusComponent
final class PasskeysScreenListComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let passkeys: [TelegramPasskey]
let addPasskeyAction: () -> Void
let deletePasskeyAction: (String) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
passkeys: [TelegramPasskey],
addPasskeyAction: @escaping () -> Void,
deletePasskeyAction: @escaping (String) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.passkeys = passkeys
self.addPasskeyAction = addPasskeyAction
self.deletePasskeyAction = deletePasskeyAction
}
@@ -42,6 +48,9 @@ final class PasskeysScreenListComponent: Component {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.insets != rhs.insets {
return false
}
@@ -101,6 +110,11 @@ final class PasskeysScreenListComponent: Component {
self.component = component
self.state = state
var maxPasskeys = 5
if let data = component.context.currentAppConfiguration.with({ $0 }).data, let maxValue = data["passkeys_account_passkeys_max"] as? Double {
maxPasskeys = Int(maxValue)
}
self.backgroundColor = component.theme.list.blocksBackgroundColor
let sideInset: CGFloat = 16.0 + component.insets.left
@@ -227,7 +241,7 @@ final class PasskeysScreenListComponent: Component {
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: passkey.name,
string: passkey.name.isEmpty ? "Passkey" : passkey.name, //TODO:localize
font: Font.regular(17.0),
textColor: component.theme.list.itemPrimaryTextColor
)),
@@ -247,11 +261,45 @@ final class PasskeysScreenListComponent: Component {
false
),
accessory: nil,
contextOptions: [ListActionItemComponent.ContextOption(
id: "delete",
title: component.strings.Common_Delete,
color: component.theme.list.itemDisclosureActions.destructive.fillColor,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.deletePasskeyAction(passkeyId)
}
)],
action: nil,
highlighting: .default
))))
}
if component.passkeys.count < maxPasskeys {
listSectionItems.append(AnyComponentWithIdentity(id: "_add", component: AnyComponent(ListActionItemComponent(
theme: component.theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "Create Passkey", //TODO:localize
font: Font.regular(17.0),
textColor: component.theme.list.itemAccentColor
)),
maximumNumberOfLines: 1
)))
], alignment: .left, spacing: 2.0)),
leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent(
name: "Chat List/AddIcon",
tintColor: component.theme.list.itemAccentColor
))), false),
accessory: nil,
action: { [weak self] _ in
guard let self, let component = self.component else {
return
}
component.deletePasskeyAction(passkeyId)
component.addPasskeyAction()
},
highlighting: .default
))))