support div-tooltip.mode

commit_hash:949be67575b7f3da8a6d40d3341bb8c681123d76
This commit is contained in:
babaevmm
2025-02-11 12:35:40 +03:00
parent 51d571108e
commit fa7bdf80f8
9 changed files with 214 additions and 60 deletions
@@ -18,6 +18,13 @@ extension DivTooltip {
return await tooltipViewFactory.makeView(div: self.div, tooltipId: self.id)
}
let mode: BlockTooltip.Mode = switch mode {
case .divTooltipModeModal:
.modal
case .divTooltipModeNonModal:
.nonModal
}
return try BlockTooltip(
id: id,
// Legacy behavior. Views should be created with tooltipViewFactory.
@@ -28,7 +35,8 @@ extension DivTooltip {
useLegacyWidth: context.flagsInfo.useTooltipLegacyWidth,
tooltipViewFactory: tooltipViewFactory,
closeByTapOutside: resolveCloseByTapOutside(expressionResolver),
tapOutsideActions: tapOutsideActions?.uiActions(context: context) ?? []
tapOutsideActions: tapOutsideActions?.uiActions(context: context) ?? [],
mode: mode
)
}
}
@@ -82,7 +82,7 @@ final class SnapshotTestRunner {
manager: DefaultTooltipManager,
check: CheckAction
) async throws {
guard let tooltipWindow = manager.tooltipWindow else {
guard let tooltipWindow = manager.modalTooltipWindow else {
return try check(nil)
}
@@ -21,6 +21,11 @@ public struct BlockTooltip: Equatable {
case center
}
public enum Mode: Equatable {
case modal
case nonModal
}
public let id: String
public let block: Block
public let duration: TimeInterval
@@ -30,6 +35,7 @@ public struct BlockTooltip: Equatable {
public let tooltipViewFactory: TooltipViewFactory?
public let closeByTapOutside: Bool
public let tapOutsideActions: [UserInterfaceAction]
public let mode: Mode
public init(
id: String,
@@ -40,7 +46,8 @@ public struct BlockTooltip: Equatable {
useLegacyWidth: Bool = true,
tooltipViewFactory: TooltipViewFactory? = nil,
closeByTapOutside: Bool = true,
tapOutsideActions: [UserInterfaceAction] = []
tapOutsideActions: [UserInterfaceAction] = [],
mode: Mode = .modal
) {
self.id = id
self.block = block
@@ -51,6 +58,7 @@ public struct BlockTooltip: Equatable {
self.tooltipViewFactory = tooltipViewFactory
self.closeByTapOutside = closeByTapOutside
self.tapOutsideActions = tapOutsideActions
self.mode = mode
}
public static func ==(lhs: BlockTooltip, rhs: BlockTooltip) -> Bool {
@@ -61,6 +69,7 @@ public struct BlockTooltip: Equatable {
lhs.useLegacyWidth == rhs.useLegacyWidth &&
lhs.block.equals(rhs.block) &&
lhs.closeByTapOutside == rhs.closeByTapOutside &&
lhs.tapOutsideActions == rhs.tapOutsideActions
lhs.tapOutsideActions == rhs.tapOutsideActions &&
lhs.mode == rhs.mode
}
}
@@ -68,6 +68,7 @@ public class DefaultTooltipManager: TooltipManager {
public let duration: TimeInterval
public let closeByTapOutside: Bool
public let tapOutsideActions: [UserInterfaceAction]
public let isModal: Bool
public let view: VisibleBoundsTrackingView
}
@@ -76,9 +77,14 @@ public class DefaultTooltipManager: TooltipManager {
private var handleAction: (UIActionEvent) -> Void
private var existingAnchorViews = WeakCollection<TooltipAnchorView>()
private var showingTooltips = [String: TooltipContainerView]()
private(set) var tooltipWindow: UIWindow?
private(set) var modalTooltipWindow: UIWindow?
private var previousOrientation = UIDevice.current.orientation
private var foregroundWindowScene: UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes
return scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
}
public init(
shownTooltips: Property<Set<String>> = Property(),
handleAction: @escaping (UIActionEvent) -> Void = { _ in }
@@ -95,13 +101,14 @@ public class DefaultTooltipManager: TooltipManager {
}
public func showTooltip(info: TooltipInfo) {
setupTooltipWindow()
guard let tooltipWindow,
setupModalTooltipWindow()
guard let modalTooltipWindow,
let foregroundWindowScene,
let currentKeyWindow = foregroundWindowScene.windows.first(where: { $0.isKeyWindow }),
!showingTooltips.keys.contains(info.id)
else { return }
let windowBounds = tooltipWindow.bounds.inset(by: tooltipWindow.safeAreaInsets)
let windowBounds = modalTooltipWindow.bounds.inset(by: modalTooltipWindow.safeAreaInsets)
Task { @MainActor in
guard let tooltip = await existingAnchorViews.compactMap(
@@ -112,23 +119,34 @@ public class DefaultTooltipManager: TooltipManager {
tooltipView: tooltip.view,
closeByTapOutside: tooltip.closeByTapOutside,
tapOutsideActions: tooltip.tapOutsideActions,
isModal: tooltip.isModal,
handleAction: handleAction,
onCloseAction: { [weak self] in
self?.showingTooltips.removeValue(forKey: tooltip.id)
self?.tooltipWindow?.isHidden = true
guard let self else { return }
showingTooltips.removeValue(forKey: tooltip.id)
if !showingTooltips.contains(where: { $1.isModal }) {
modalTooltipWindow.isHidden = true
}
}
)
// Passing the statusBarStyle control to `rootViewController` of the main window
let vc = ProxyViewController(
viewController: UIApplication.shared.delegate?.window??
.rootViewController ?? UIViewController()
)
vc.view = view
// Window won't rotate if `rootViewController` is not set
tooltipWindow.rootViewController = vc
tooltipWindow.isHidden = false
tooltipWindow.makeKeyAndVisible()
view.frame = tooltipWindow.bounds
if tooltip.isModal {
// Passing the statusBarStyle control to `rootViewController` of the main window
let vc = ProxyViewController(
viewController: UIApplication.shared.delegate?.window??
.rootViewController ?? UIViewController()
)
vc.view = view
// Window won't rotate if `rootViewController` is not set
modalTooltipWindow.rootViewController = vc
modalTooltipWindow.isHidden = false
modalTooltipWindow.makeKeyAndVisible()
view.frame = modalTooltipWindow.bounds
} else {
currentKeyWindow.addSubview(view)
view.frame = currentKeyWindow.bounds
}
showingTooltips[info.id] = view
if !tooltip.duration.isZero {
try await Task.sleep(nanoseconds: UInt64(tooltip.duration.nanoseconds))
@@ -152,7 +170,7 @@ public class DefaultTooltipManager: TooltipManager {
public func reset() {
showingTooltips = [:]
tooltipWindow = nil
modalTooltipWindow = nil
}
public func setHandler(_ handler: @escaping (UIActionEvent) -> Void) {
@@ -176,15 +194,13 @@ public class DefaultTooltipManager: TooltipManager {
previousOrientation = orientation
}
private func setupTooltipWindow() {
if tooltipWindow == nil {
guard let windowScene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
else { return }
tooltipWindow = UIWindow(windowScene: windowScene)
tooltipWindow?.windowLevel = UIWindow.Level.alert + 1
tooltipWindow?.isHidden = true
}
private func setupModalTooltipWindow() {
guard modalTooltipWindow == nil,
let windowScene = foregroundWindowScene else { return }
modalTooltipWindow = UIWindow(windowScene: windowScene)
modalTooltipWindow?.windowLevel = UIWindow.Level.alert + 1
modalTooltipWindow?.isHidden = true
}
}
@@ -215,6 +231,7 @@ extension TooltipAnchorView {
duration: tooltip.duration,
closeByTapOutside: tooltip.closeByTapOutside,
tapOutsideActions: tooltip.tapOutsideActions,
isModal: tooltip.mode == .modal,
view: tooltipView
)
}
@@ -3,12 +3,14 @@ import UIKit
import VGSL
public final class TooltipContainerView: UIView, UIActionEventPerforming {
let isModal: Bool
private let tooltipView: VisibleBoundsTrackingView
private let closeByTapOutside: Bool
private let tapOutsideActions: [UserInterfaceAction]
private let handleAction: (LayoutKit.UIActionEvent) -> Void
private let onCloseAction: Action
private var isClosing = false
private var lastNonZeroBounds: CGRect?
private var onVisibleBoundsChanged: Action?
@@ -16,12 +18,14 @@ public final class TooltipContainerView: UIView, UIActionEventPerforming {
tooltipView: VisibleBoundsTrackingView,
closeByTapOutside: Bool,
tapOutsideActions: [UserInterfaceAction],
isModal: Bool,
handleAction: @escaping (LayoutKit.UIActionEvent) -> Void,
onCloseAction: @escaping Action
) {
self.tooltipView = tooltipView
self.closeByTapOutside = closeByTapOutside
self.tapOutsideActions = tapOutsideActions
self.isModal = isModal
self.handleAction = handleAction
self.onCloseAction = onCloseAction
let tooltipBounds = tooltipView.bounds
@@ -31,8 +35,10 @@ public final class TooltipContainerView: UIView, UIActionEventPerforming {
super.init(frame: .zero)
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapRecognizer)
if isModal {
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapRecognizer)
}
addSubview(tooltipView)
}
@@ -44,16 +50,21 @@ public final class TooltipContainerView: UIView, UIActionEventPerforming {
@objc private func handleTap(_ sender: UITapGestureRecognizer) {
let point = sender.location(in: self)
let isPointInsideTooltip = tooltipView.point(
inside: tooltipView.convert(point, from: self),
with: nil
)
if !isPointInsideTooltip {
if !isPointInsideTooltip(point) {
performTapOutsideActions()
}
}
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !isPointInsideTooltip(point, event: event), !isModal {
if closeByTapOutside {
close()
DispatchQueue.main.async {
self.close()
}
}
return nil
} else {
return super.hitTest(point, with: event)
}
}
@@ -78,6 +89,8 @@ public final class TooltipContainerView: UIView, UIActionEventPerforming {
}
public func close() {
guard !isClosing else { return }
isClosing = true
self.tooltipView.onVisibleBoundsChanged(from: tooltipView.bounds, to: .zero)
removeFromParentAnimated(completion: {
self.onCloseAction()
@@ -89,5 +102,13 @@ public final class TooltipContainerView: UIView, UIActionEventPerforming {
UIActionEvent(uiAction: $0, originalSender: self)
}
perform(uiActionEvents: uiActionEvents, from: self)
if closeByTapOutside {
close()
}
}
private func isPointInsideTooltip(_ point: CGPoint, event: UIEvent? = nil) -> Bool {
tooltipView.point(inside: tooltipView.convert(point, from: self), with: event)
}
}
@@ -43,6 +43,7 @@ final class BlockTooltipTests: XCTestCase {
tooltipView: TestView(),
closeByTapOutside: true,
tapOutsideActions: [],
isModal: true,
handleAction: { _ in },
onCloseAction: {}
)
+3 -1
View File
@@ -1,7 +1,9 @@
{
"type": "object",
"$description": "translations.json#/div_tooltip_mode_non_modal",
"platforms": [],
"platforms": [
"ios"
],
"properties": {
"type": {
"type": "string",
+23 -19
View File
@@ -565,19 +565,19 @@
],
"steps": [
"Set russian language on device",
"Turn on Screen reader and set order of elements description to '\u0421\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435,\u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435,\u0442\u0438\u043f'",
"Set focus on '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 1'",
"Turn on Screen reader and set order of elements description to 'Состояние,название,тип'",
"Set focus on 'Элемент 1'",
"Listen to Screen reader",
"Double tap on screen",
"Set focus on '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 2'",
"Set focus on 'Элемент 2'",
"Listen to Screen reader",
"Set focus on '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 3'"
"Set focus on 'Элемент 3'"
],
"expected_results": [
"On '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 1' Screen reader should say 'C\u0435\u0439\u0447\u0430\u0441 \u0432 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438 0; \u042d\u043b\u0435\u043c\u0435\u043d\u0442 1; \u041a\u043d\u043e\u043f\u043a\u0430; \u041a\u043e\u0441\u043d\u0438\u0442\u0435\u0441\u044c \u0434\u0432\u0430\u0436\u0434\u044b, \u0447\u0442\u043e\u0431\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c'",
"After double tap Screen reader should say '\u0421\u0435\u0439\u0447\u0430\u0441 \u0432 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438 1'",
"On '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 2' Screen reader should say '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 2; \u041a\u043d\u043e\u043f\u043a\u0430; \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u043a\u043d\u043e\u043f\u043a\u0443, \u0447\u0442\u043e\u0431\u044b \u0447\u0442\u043e-\u0442\u043e \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u043e'",
"On '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 3' Screen reader should say '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 3; \u041a\u043d\u043e\u043f\u043a\u0430'"
"On 'Элемент 1' Screen reader should say 'Cейчас в состоянии 0; Элемент 1; Кнопка; Коснитесь дважды, чтобы активировать'",
"After double tap Screen reader should say 'Сейчас в состоянии 1'",
"On 'Элемент 2' Screen reader should say 'Элемент 2; Кнопка; Нажмите на кнопку, чтобы что-то произошло'",
"On 'Элемент 3' Screen reader should say 'Элемент 3; Кнопка'"
],
"file": "accessibility/descriptions.json"
},
@@ -651,8 +651,8 @@
"Focus on items from top to bottom"
],
"expected_results": [
"When focusing on 1 item you'll hear '\u042d\u0442\u043e \u042d\u043b\u0435\u043c\u0435\u043d\u0442 1'",
"When focusing on group of 2 and 3 items you will hear '\u042d\u0442\u043e \u042d\u043b\u0435\u043c\u0435\u043d\u0442 2, \u042d\u0442\u043e \u042d\u043b\u0435\u043c\u0435\u043d\u0442 3'",
"When focusing on 1 item you'll hear 'Это Элемент 1'",
"When focusing on group of 2 and 3 items you will hear 'Это Элемент 2, Это Элемент 3'",
"Can't focus 4 element"
],
"file": "accessibility/mode/default.json"
@@ -672,9 +672,9 @@
],
"expected_results": [
"Focusing on all card",
"You will hear '\u042d\u0442\u043e \u042d\u043b\u0435\u043c\u0435\u043d\u0442 1, \u042d\u0442\u043e \u042d\u043b\u0435\u043c\u0435\u043d\u0442 2, \u042d\u0442\u043e \u042d\u043b\u0435\u043c\u0435\u043d\u0442 3'",
"On activation menu with text '\u042d\u043b\u0435\u043c\u0435\u043d\u0442 1, \u042d\u043b\u0435\u043c\u0435\u043d\u0442 2, \u042d\u043b\u0435\u043c\u0435\u043d\u0442 3' appears",
"On focus Screen reader should not say '\u041a\u043e\u0441\u043d\u0438\u0442\u0435\u0441\u044c \u0434\u0432\u0430\u0436\u0434\u044b \u0438 \u0443\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0439\u0442\u0435'"
"You will hear 'Это Элемент 1, Это Элемент 2, Это Элемент 3'",
"On activation menu with text 'Элемент 1, Элемент 2, Элемент 3' appears",
"On focus Screen reader should not say 'Коснитесь дважды и удерживайте'"
],
"file": "accessibility/mode/merge.json"
},
@@ -693,8 +693,8 @@
],
"expected_results": [
"Focus on 5-th item",
"\u0423\u0441\u043b\u044b\u0448\u0438\u043c '\u042d\u0442\u043e \u042d\u043b\u0435\u043c\u0435\u043d\u0442 5'",
"\u041f\u0440\u0438 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c Screen reader \u043d\u0430 \u043a\u0430\u0436\u0434\u043e\u0439 \u043a\u043d\u043e\u043f\u043a\u0435 \u043f\u0440\u0438 \u0442\u0430\u043f\u0435 \u0434\u043e\u043b\u0436\u043d\u043e \u043f\u043e\u044f\u0432\u043b\u044f\u0442\u044c\u0441\u044f \u043c\u0435\u043d\u044e \u0441 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c \u044d\u0442\u043e\u0439 \u043a\u043d\u043e\u043f\u043a\u0438"
"Услышим 'Это Элемент 5'",
"При выключенном Screen reader на каждой кнопке при тапе должно появляться меню с названием этой кнопки"
],
"file": "accessibility/mode/exclude.json"
},
@@ -2082,7 +2082,7 @@
},
{
"title": "Custom card",
"case_id": 93,
"case_id": 93,
"platforms": [
"android",
"web"
@@ -2599,14 +2599,18 @@
"Try to tap outside of tooltip",
"Close tooltip by cross button",
"Tap on second button",
"Tap outside of tooltip"
"Tap outside of tooltip",
"Tap on third button",
"Tap on other buttons"
],
"expected_results": [
"First tooltip is shown",
"First tooltip isn't closed by tapping outside of the tooltip",
"First tooltip is closed",
"Second tooltip is shown",
"Label text changed to `Outside actions called: true`, label text color is green"
"Label text changed to `Outside actions called: true`, label text color is green",
"Third tooltip is shown",
"First and second buttons are clickable during showing of third non modal tooltip"
],
"file": "tooltips-with-tap-outside-properties.json"
},
@@ -2764,7 +2768,7 @@
{
"title": "Patch gallery with typed action",
"case_id": 192,
"platforms": [
"platforms": [
"android",
"web"
],
@@ -250,6 +250,98 @@
"type": "text",
"text": "Outside actions called: @{outside_actions_called}",
"text_color": "@{(outside_actions_called == 'true') ? '#00FF00' : '#000000'}"
},
{
"type": "button",
"text": "tooltip mode = non_modal",
"tooltips": [
{
"id": "tooltip_3",
"position": "bottom",
"duration": 0,
"offset": {
"x": {
"value": 0
},
"y": {
"value": 50
}
},
"mode": {
"type": "non_modal"
},
"close_by_tap_outside": true,
"div": {
"type": "container",
"orientation": "overlap",
"items": [
{
"type": "container",
"paddings": {
"left": 16,
"top": 16,
"right": 16,
"bottom": 16
},
"items": [
{
"type": "text",
"width": {
"type": "wrap_content"
},
"paddings": {
"left": 16,
"top": 16,
"right": 16,
"bottom": 16
},
"background": [
{
"type": "solid",
"color": "#799eb7"
}
],
"border": {
"corner_radius": 6
},
"text_color": "#2C3E50",
"text": "Non modal tooltip"
}
]
},
{
"type": "image",
"width": {
"type": "fixed",
"value": 20
},
"height": {
"type": "fixed",
"value": 20
},
"margins": {
"left": 8,
"top": 8
},
"action": {
"log_id": "hide_tooltip",
"url": "div-action://hide_tooltip?id=tooltip_3"
},
"image_url": "https://yastatic.net/s3/home/div/div_fullscreens/cross2.3.png",
"tint_color": "#000000",
"alignment_horizontal": "left",
"alignment_vertical": "top"
}
]
}
}
],
"actions": [
{
"log_id": "show_tooltip",
"url": "div-action://show_tooltip?id=tooltip_3&multiple=true"
}
]
}
]
}