diff --git a/.mapping.json b/.mapping.json index c270958e1..d70234135 100644 --- a/.mapping.json +++ b/.mapping.json @@ -11803,6 +11803,9 @@ "client/ios/DivKit/Animators/ProgressInterpolator.swift":"divkit/public/client/ios/DivKit/Animators/ProgressInterpolator.swift", "client/ios/DivKit/Animators/ValueInterpolator.swift":"divkit/public/client/ios/DivKit/Animators/ValueInterpolator.swift", "client/ios/DivKit/Debug/Block+DebugInfo.swift":"divkit/public/client/ios/DivKit/Debug/Block+DebugInfo.swift", + "client/ios/DivKit/Debug/DebugBlock+UIViewRenderableBlock.swift":"divkit/public/client/ios/DivKit/Debug/DebugBlock+UIViewRenderableBlock.swift", + "client/ios/DivKit/Debug/DebugBlock.swift":"divkit/public/client/ios/DivKit/Debug/DebugBlock.swift", + "client/ios/DivKit/Debug/DebugErrorCollector.swift":"divkit/public/client/ios/DivKit/Debug/DebugErrorCollector.swift", "client/ios/DivKit/Debug/DebugParams.swift":"divkit/public/client/ios/DivKit/Debug/DebugParams.swift", "client/ios/DivKit/Debug/ErrorListView.swift":"divkit/public/client/ios/DivKit/Debug/ErrorListView.swift", "client/ios/DivKit/DivAccessibilityElementsStorage.swift":"divkit/public/client/ios/DivKit/DivAccessibilityElementsStorage.swift", diff --git a/client/ios/DivKit/Debug/Block+DebugInfo.swift b/client/ios/DivKit/Debug/Block+DebugInfo.swift index 487cb1995..58d7976df 100644 --- a/client/ios/DivKit/Debug/Block+DebugInfo.swift +++ b/client/ios/DivKit/Debug/Block+DebugInfo.swift @@ -7,77 +7,23 @@ import VGSL extension Block { func addingDebugInfo(context: DivBlockModelingContext) -> Block { let debugParams = context.debugParams - guard debugParams.isDebugInfoEnabled else { + guard debugParams.isDebugInfoEnabled, + let debugErrorCollector = context.debugErrorCollector else { return self } - let errors = context.errorsStorage.errors - let errorsCount = errors.count - guard errorsCount > 0 else { - return self - } - - let counterText = errorsCount > maxCount - ? "\(maxCount)+" - : "\(errorsCount)" - - let typo = Typo(font: context.fontProvider.font(size: 14)) - .with(color: .white) - - let action = UserInterfaceAction( - payload: .url(DebugInfoBlock.showOverlayURL), - path: context.parentPath + "div_errors_indicator", - accessibilityElement: nil - ) - - let counter = TextBlock( - widthTrait: .intrinsic, - text: counterText.with(typo: typo) - ) - - let indicator = counter - .addingVerticalGaps(errorsButtonCounterGaps) - .addingHorizontalGaps(calculateCounterHorizontalGaps(counter: counter)) - .addingDecorations( - boundary: .clipCorner(radius: 10), - backgroundColor: .red - ) - .addingEdgeInsets(debugParams.errorCounterInsets) - .addingEdgeGaps(2) - .addingAccessibilityID(withTraits: ( - "divLayoutErrorCounter", - .button - )) - .addingDecorations(action: action) - - let debugInfoBlock = DebugInfoBlock( - child: indicator, - showDebugInfo: { - #if os(iOS) - debugParams.showDebugInfo(ErrorListView(errors: errors.map(\.prettyMessage))) - #else - return - #endif - } + let debugBlock = DebugBlock( + errorCollector: debugErrorCollector, + showDebugInfo: debugParams.showDebugInfo ) let block = LayeredBlock( widthTrait: calculatedWidthTrait, heightTrait: calculatedHeightTrait, - children: [self, debugInfoBlock] + verticalChildrenAlignment: .center, + children: [self, debugBlock] ) return block } } - -private func calculateCounterHorizontalGaps(counter: Block) -> CGFloat { - let additionalGap = (counter.intrinsicSize.height - counter.intrinsicSize.width) / 2 - return max( - errorsButtonCounterGaps, - errorsButtonCounterGaps + additionalGap - ) -} - -private let errorsButtonCounterGaps: CGFloat = 4 -private let maxCount = 9999 diff --git a/client/ios/DivKit/Debug/DebugBlock+UIViewRenderableBlock.swift b/client/ios/DivKit/Debug/DebugBlock+UIViewRenderableBlock.swift new file mode 100644 index 000000000..7b784d588 --- /dev/null +++ b/client/ios/DivKit/Debug/DebugBlock+UIViewRenderableBlock.swift @@ -0,0 +1,97 @@ +import UIKit + +import LayoutKit +import VGSL + +extension DebugBlock: UIViewRenderable { + static func makeBlockView() -> any LayoutKit.BlockView { + DebugBlockView() + } + + func canConfigureBlockView(_ view: any LayoutKit.BlockView) -> Bool { + view is DebugBlockView + } + + func configureBlockView( + _ view: BlockView, + observer _: ElementStateObserver?, + overscrollDelegate _: ScrollDelegate?, + renderingDelegate _: RenderingDelegate? + ) { + (view as? DebugBlockView)?.configure( + errorCollector: errorCollector, + showDebugInfo: showDebugInfo + ) + } +} + +private final class DebugBlockView: BlockView, VisibleBoundsTrackingContainer { + let effectiveBackgroundColor: UIColor? = nil + let visibleBoundsTrackingSubviews: [VisibleBoundsTrackingView] = [] + + private var showDebugInfo: ((ViewType) -> Void)? + private var errorCollector: DebugErrorCollector? + private let disposePool = AutodisposePool() + + private let errorsLabel: UILabel = { + let label = UILabel(frame: CGRect(origin: .zero, size: CGSize(squareDimension: 50.0))) + label.text = "0" + label.numberOfLines = 1 + label.font = .systemFont(ofSize: 14) + label.textColor = .white + label.textAlignment = .center + label.backgroundColor = .red + label.isHidden = true + return label + }() + + init() { + super.init(frame: .zero) + addSubview(errorsLabel) + accessibilityIdentifier = "divLayoutErrorCounter" + accessibilityTraits = .button + clipsToBounds = true + addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(handleTapGesture(_:)) + )) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + errorCollector: DebugErrorCollector, + showDebugInfo: @escaping (ViewType) -> Void + ) { + self.showDebugInfo = showDebugInfo + if self.errorCollector !== errorCollector { + self.errorCollector = errorCollector + errorCollector.observableErrorCount.currentAndNewValues.addObserver { [weak self] _ in + self?.updateCountLabel() + }.dispose(in: disposePool) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + errorsLabel.frame = bounds + layer.cornerRadius = bounds.size.height / 2 + } + + private func updateCountLabel() { + let errorsCount = errorCollector?.totalErrorCount ?? 0 + errorsLabel.isHidden = errorsCount == 0 + errorsLabel.text = "\(min(maxCount, errorsCount))" + } + + @objc func handleTapGesture(_: UITapGestureRecognizer) { + guard let showDebugInfo, let errorCollector, errorCollector.totalErrorCount > 0 else { return } + showDebugInfo(ErrorListView(errors: errorCollector.errorList)) + } +} + +private let showOverlayURL = URL(string: "debugInfo://show")! +private let maxCount = 9999 diff --git a/client/ios/DivKit/Debug/DebugBlock.swift b/client/ios/DivKit/Debug/DebugBlock.swift new file mode 100644 index 000000000..37f308976 --- /dev/null +++ b/client/ios/DivKit/Debug/DebugBlock.swift @@ -0,0 +1,48 @@ +import Foundation + +import LayoutKit +import VGSL + +final class DebugBlock: BlockWithTraits, LayoutCachingDefaultImpl { + let widthTrait = LayoutTrait.fixed(buttonSize) + let heightTrait = LayoutTrait.fixed(buttonSize) + + var intrinsicContentWidth: CGFloat { + widthTrait.fixedValue ?? 0.0 + } + + func intrinsicContentHeight(forWidth _: CGFloat) -> CGFloat { + heightTrait.fixedValue ?? 0.0 + } + + let errorCollector: DebugErrorCollector + let showDebugInfo: (ViewType) -> Void + + init( + errorCollector: DebugErrorCollector, + showDebugInfo: @escaping (ViewType) -> Void + ) { + self.errorCollector = errorCollector + self.showDebugInfo = showDebugInfo + } + + func equals(_ other: any LayoutKit.Block) -> Bool { + if self === other { return true } + guard let other = other as? DebugBlock else { return false } + return errorCollector === other.errorCollector + } + + var debugDescription: String { + "DebugBlock errors: \(errorCollector.debugDescription))" + } + + func getImageHolders() -> [any VGSLUI.ImageHolder] { + [] + } + + func updated(withStates _: LayoutKit.BlocksState) throws -> Self { + self + } +} + +private let buttonSize = 50.0 diff --git a/client/ios/DivKit/Debug/DebugErrorCollector.swift b/client/ios/DivKit/Debug/DebugErrorCollector.swift new file mode 100644 index 000000000..c9b1c48ec --- /dev/null +++ b/client/ios/DivKit/Debug/DebugErrorCollector.swift @@ -0,0 +1,50 @@ +import VGSLFundamentals + +final class DebugErrorCollector: DivReporter { + private let wrappedDivReporter: DivReporter + + var errorStorage: DivErrorsStorage + + private(set) var layoutErrors = [DivError]() + let observableErrorCount = ObservableProperty(initialValue: 0) + + init( + wrappedDivReporter: DivReporter, + errorStorage: DivErrorsStorage + ) { + self.wrappedDivReporter = wrappedDivReporter + self.errorStorage = errorStorage + } + + func reportError(cardId: DivCardID, error: DivError) { + wrappedDivReporter.reportError(cardId: cardId, error: error) + guard !hasError(error) else { return } + layoutErrors.append(error) + observableErrorCount.value += 1 + } + + func reportAction(cardId: DivCardID, info: DivActionInfo) { + wrappedDivReporter.reportAction(cardId: cardId, info: info) + } + + var totalErrorCount: Int { + errorStorage.errors.count + layoutErrors.count + } + + var errorList: [String] { + errorStorage.errors.map(\.prettyMessage) + layoutErrors.map(\.prettyMessage) + } + + var debugDescription: String { + "Errors: \(errorList)" + } + + private func hasError(_ error: DivError) -> Bool { + layoutErrors.contains { + $0.kind == error.kind + && $0.message == error.message + && $0.path == error.path + && $0.level == error.level + } + } +} diff --git a/client/ios/DivKit/DivBlockModelingContext.swift b/client/ios/DivKit/DivBlockModelingContext.swift index af2d15136..63267f5f8 100644 --- a/client/ios/DivKit/DivBlockModelingContext.swift +++ b/client/ios/DivKit/DivBlockModelingContext.swift @@ -30,6 +30,7 @@ public struct DivBlockModelingContext { let playerFactory: PlayerFactory? private(set) weak var parentScrollView: ScrollView? public private(set) var errorsStorage: DivErrorsStorage + let debugErrorCollector: DebugErrorCollector? private let persistentValuesStorage: DivPersistentValuesStorage let tooltipViewFactory: DivTooltipViewFactory? let functionsStorage: DivFunctionsStorage? @@ -102,6 +103,7 @@ public struct DivBlockModelingContext { scheduler: scheduler, parentScrollView: parentScrollView, errorsStorage: errorsStorage, + debugErrorCollector: nil, layoutDirection: layoutDirection, variableTracker: variableTracker, persistentValuesStorage: persistentValuesStorage, @@ -136,6 +138,7 @@ public struct DivBlockModelingContext { scheduler: Scheduling?, parentScrollView: ScrollView?, errorsStorage: DivErrorsStorage?, + debugErrorCollector: DebugErrorCollector?, layoutDirection: UserInterfaceLayoutDirection, variableTracker: DivVariableTracker?, persistentValuesStorage: DivPersistentValuesStorage?, @@ -165,6 +168,7 @@ public struct DivBlockModelingContext { self.scheduler = scheduler ?? TimerScheduler() self.parentScrollView = parentScrollView let errorsStorage = errorsStorage ?? DivErrorsStorage(errors: []) + self.debugErrorCollector = debugErrorCollector self.errorsStorage = errorsStorage self.layoutDirection = layoutDirection self.variableTracker = variableTracker diff --git a/client/ios/DivKit/DivError.swift b/client/ios/DivKit/DivError.swift index bc6b94ec9..98485a612 100644 --- a/client/ios/DivKit/DivError.swift +++ b/client/ios/DivKit/DivError.swift @@ -13,6 +13,7 @@ public protocol DivError: CustomStringConvertible { public enum DivErrorKind: String { case deserialization case blockModeling = "divModeling" + case layout case expression case unknown } @@ -105,6 +106,32 @@ struct DivBlockModelingWarning: DivError { } } +struct DivLayoutError: DivError { + public let kind = DivErrorKind.layout + public let level = DivErrorLevel.error + public let message: String + public let path: UIElementPath + + init(_ message: String, path: UIElementPath) { + self.message = message + self.path = path + DivKitLogger.error(description) + } +} + +struct DivLayoutWarning: DivError { + public let kind = DivErrorKind.layout + public let level = DivErrorLevel.warning + public let message: String + public let path: UIElementPath + + init(_ message: String, path: UIElementPath) { + self.message = message + self.path = path + DivKitLogger.warning(description) + } +} + struct DivExpressionError: Error, DivError { public let kind = DivErrorKind.expression public let message: String diff --git a/client/ios/DivKit/DivKitComponents.swift b/client/ios/DivKit/DivKitComponents.swift index 51f9663f2..7e65f54e4 100644 --- a/client/ios/DivKit/DivKitComponents.swift +++ b/client/ios/DivKit/DivKitComponents.swift @@ -50,6 +50,7 @@ public final class DivKitComponents { private let variableTracker = DivVariableTracker() private let idToPath = IdToPath() private let animatorController = DivAnimatorController() + private var debugErrorCollectors = [DivCardID: DebugErrorCollector]() /// You can create an instance of `DivKitComponents` with various optional parameters that allow /// you to customize the behavior and functionality of `DivKit` to suit your specific needs. @@ -231,6 +232,7 @@ public final class DivKitComponents { tooltipManager.reset() idToPath.reset() animatorController.reset() + debugErrorCollectors = [:] } public func reset(cardId: DivCardID) { @@ -244,6 +246,7 @@ public final class DivKitComponents { timerStorage.reset(cardId: cardId) idToPath.reset(cardId: cardId) animatorController.reset(cardId: cardId) + debugErrorCollectors[cardId] = nil } /// When using DivView, use DivData.resolve to avoid adding variables twice. @@ -313,6 +316,7 @@ public final class DivKitComponents { ? DivStateManager() : stateManagement.getStateManagerForCard(cardId: cardId) + let errorsStorage = DivErrorsStorage(errors: []) return DivBlockModelingContext( viewId: viewId, cardLogId: nil, @@ -337,7 +341,12 @@ public final class DivKitComponents { debugParams: debugParams, scheduler: nil, parentScrollView: parentScrollView, - errorsStorage: nil, + errorsStorage: errorsStorage, + debugErrorCollector: debugErrorCollector( + for: cardId, + debugParams: debugParams, + errorsStorage: errorsStorage + ), layoutDirection: layoutDirection, variableTracker: variableTracker, persistentValuesStorage: persistentValuesStorage, @@ -379,6 +388,28 @@ public final class DivKitComponents { updateAggregator.forceUpdate() } + @_spi(Internal) + public func renderingDelegate(for cardId: DivCardID) -> RenderingDelegate { + ErrorsReportingRenderingDelegate( + wrappedRenderingDelegate: tooltipManager, + cardId: cardId, + divReporter: debugErrorCollectors[cardId] + ) + } + + private func debugErrorCollector( + for cardId: DivCardID, + debugParams: DebugParams, + errorsStorage: DivErrorsStorage + ) -> DebugErrorCollector? { + guard debugParams.isDebugInfoEnabled else { return nil } + let collector = debugErrorCollectors.getOrCreate(cardId, factory: { + DebugErrorCollector(wrappedDivReporter: reporter, errorStorage: errorsStorage) + }) + collector.errorStorage = errorsStorage + return collector + } + private func onVariablesChanged(event: DivVariablesStorage.ChangeEvent) { switch event.kind { case let .global(variables): @@ -397,3 +428,47 @@ let defaultPlayerFactory: PlayerFactory? = DefaultPlayerFactory() #else let defaultPlayerFactory: PlayerFactory? = nil #endif + +private final class ErrorsReportingRenderingDelegate: RenderingDelegate { + private let wrappedRenderingDelegate: RenderingDelegate + private let cardId: DivCardID + private let divReporter: DivReporter? + + init( + wrappedRenderingDelegate: RenderingDelegate, + cardId: DivCardID, + divReporter: DivReporter? + ) { + self.wrappedRenderingDelegate = wrappedRenderingDelegate + self.cardId = cardId + self.divReporter = divReporter + } + + func reportRenderingError(message: String, isWarning: Bool, path: UIElementPath) { + wrappedRenderingDelegate.reportRenderingError( + message: message, + isWarning: isWarning, + path: path + ) + guard let divReporter else { return } + let error: DivError = if isWarning { + DivLayoutWarning(message, path: path) + } + else { + DivLayoutError(message, path: path) + } + divReporter.reportError(cardId: cardId, error: error) + } + + func mapView(_ view: any LayoutKit.BlockView, to id: LayoutKit.BlockViewID) { + wrappedRenderingDelegate.mapView(view, to: id) + } + + func tooltipAnchorViewAdded(anchorView: any LayoutKit.TooltipAnchorView) { + wrappedRenderingDelegate.tooltipAnchorViewAdded(anchorView: anchorView) + } + + func tooltipAnchorViewRemoved(anchorView: any LayoutKit.TooltipAnchorView) { + wrappedRenderingDelegate.tooltipAnchorViewRemoved(anchorView: anchorView) + } +} diff --git a/client/ios/DivKit/Extensions/DivAction/DivActionsHolder.swift b/client/ios/DivKit/Extensions/DivAction/DivActionsHolder.swift index 72f61769a..0469f2ea7 100644 --- a/client/ios/DivKit/Extensions/DivAction/DivActionsHolder.swift +++ b/client/ios/DivKit/Extensions/DivAction/DivActionsHolder.swift @@ -72,7 +72,8 @@ extension Block { actionAnimation: actionsHolder.actionAnimation .resolveActionAnimation(context.expressionResolver), doubleTapActions: doubletapActions, - longTapActions: longtapActions + longTapActions: longtapActions, + path: context.parentPath ) } } diff --git a/client/ios/DivKit/Views/DivView.swift b/client/ios/DivKit/Views/DivView.swift index 2c5ca519f..7c553965e 100644 --- a/client/ios/DivKit/Views/DivView.swift +++ b/client/ios/DivKit/Views/DivView.swift @@ -180,17 +180,23 @@ public final class DivView: VisibleBoundsTrackingView { } private func update(block: Block) { + let renderingDelegate: RenderingDelegate? + if blockProvider?.id != nil, let divCardId = blockProvider?.cardId { + renderingDelegate = divKitComponents.renderingDelegate(for: divCardId) + } else { + renderingDelegate = nil + } if let blockView, block.canConfigureBlockView(blockView) { block.configureBlockView( blockView, observer: self, overscrollDelegate: nil, - renderingDelegate: divKitComponents.tooltipManager + renderingDelegate: renderingDelegate ) } else { blockView = block.makeBlockView( observer: self, - renderingDelegate: divKitComponents.tooltipManager + renderingDelegate: renderingDelegate ) } invalidateIntrinsicContentSize() diff --git a/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/Block+Decorations.swift b/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/Block+Decorations.swift index d4bee4c65..42a3c72f2 100644 --- a/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/Block+Decorations.swift +++ b/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/Block+Decorations.swift @@ -28,7 +28,8 @@ extension Block { tooltips: [BlockTooltip]? = nil, forceWrapping: Bool, accessibilityElement: AccessibilityElement? = nil, - reuseId: String? + reuseId: String?, + path: UIElementPath? ) -> Block { let hasAlpha = alpha != nil && alpha?.isApproximatelyEqualTo(1) != true let anythingToApplyExceptBoundary = @@ -72,7 +73,8 @@ extension Block { visibilityParams: visibilityParams ?? block.visibilityParams, tooltips: [tooltips, block.tooltips].compactMap { $0 }.flatMap { $0 }, accessibilityElement: accessibilityElement, - reuseId: reuseId + reuseId: reuseId, + path: path ) } @@ -97,7 +99,8 @@ extension Block { visibilityParams: visibilityParams, tooltips: tooltips ?? [], accessibilityElement: accessibilityElement, - reuseId: reuseId + reuseId: reuseId, + path: path ) } @@ -126,7 +129,8 @@ extension Block { tooltips: [BlockTooltip]? = nil, forceWrapping: Bool = false, accessibilityElement: AccessibilityElement? = nil, - reuseId: String? = nil + reuseId: String? = nil, + path: UIElementPath? = nil ) -> Block { let decoratedBlock = applyDecoratingBlockProperties( boundary: boundary, @@ -144,7 +148,8 @@ extension Block { tooltips: tooltips, forceWrapping: forceWrapping, accessibilityElement: accessibilityElement, - reuseId: reuseId + reuseId: reuseId, + path: path ) return decoratedBlock.shaded(with: shadow) } diff --git a/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/DecoratingBlock.swift b/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/DecoratingBlock.swift index 3ed087be0..14a27e30f 100644 --- a/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/DecoratingBlock.swift +++ b/client/ios/LayoutKit/LayoutKit/Blocks/Decorations/DecoratingBlock.swift @@ -26,6 +26,7 @@ final class DecoratingBlock: WrapperBlock { let tooltips: [BlockTooltip] let accessibilityElement: AccessibilityElement? let reuseId: String + let path: UIElementPath? init( child: Block, @@ -44,7 +45,8 @@ final class DecoratingBlock: WrapperBlock { visibilityParams: VisibilityParams? = nil, tooltips: [BlockTooltip] = [], accessibilityElement: AccessibilityElement? = nil, - reuseId: String? = nil + reuseId: String? = nil, + path: UIElementPath? = nil ) { self.child = child self.backgroundColor = backgroundColor @@ -63,6 +65,7 @@ final class DecoratingBlock: WrapperBlock { self.tooltips = tooltips self.accessibilityElement = accessibilityElement self.reuseId = reuseId ?? DecoratingBlock.defaultReuseId + self.path = path } var intrinsicContentWidth: CGFloat { @@ -148,7 +151,8 @@ extension DecoratingBlock { visibilityParams: VisibilityParams? = nil, tooltips: [BlockTooltip]? = nil, accessibilityElement: AccessibilityElement? = nil, - reuseId: String? = nil + reuseId: String? = nil, + path: UIElementPath? = nil ) -> DecoratingBlock { DecoratingBlock( child: child ?? self.child, @@ -167,7 +171,8 @@ extension DecoratingBlock { visibilityParams: visibilityParams ?? self.visibilityParams, tooltips: tooltips ?? self.tooltips, accessibilityElement: accessibilityElement ?? self.accessibilityElement, - reuseId: reuseId ?? self.reuseId + reuseId: reuseId ?? self.reuseId, + path: path ?? self.path ) } } diff --git a/client/ios/LayoutKit/LayoutKit/UI/Blocks/DecoratingBlock+UIViewRenderableBlock.swift b/client/ios/LayoutKit/LayoutKit/UI/Blocks/DecoratingBlock+UIViewRenderableBlock.swift index 8c92129c7..2dfc06dc3 100644 --- a/client/ios/LayoutKit/LayoutKit/UI/Blocks/DecoratingBlock+UIViewRenderableBlock.swift +++ b/client/ios/LayoutKit/LayoutKit/UI/Blocks/DecoratingBlock+UIViewRenderableBlock.swift @@ -48,7 +48,8 @@ extension DecoratingBlock { visibilityParams: visibilityParams, tooltips: tooltips, accessibility: accessibilityElement, - reuseId: reuseId + reuseId: reuseId, + path: path ) view.configure( model: model, @@ -122,6 +123,7 @@ private final class DecoratingView: UIControl, BlockViewProtocol, VisibleBoundsT let tooltips: [BlockTooltip] let accessibility: AccessibilityElement? let reuseId: String? + let path: UIElementPath? var hasResponsiveUI: Bool { actions.hasPayload || longTapActions.hasPayload || doubleTapActions.hasPayload @@ -257,6 +259,18 @@ private final class DecoratingView: UIControl, BlockViewProtocol, VisibleBoundsT doubleTapRecognizer?.isEnabled = model.shouldHandleDoubleTap longPressRecognizer?.isEnabled = model.shouldHandleLongTap } + + private func checkTouchableArea() { + guard tapRecognizer != nil || doubleTapRecognizer != nil || longPressRecognizer != nil else { return } + guard !bounds.size.isApproximatelyEqualTo(.zero) else { return } + if bounds.width < 44 || bounds.height < 44 { + renderingDelegate?.reportRenderingError( + message: "Touchable view is too small: \(bounds.size), \(model.child)", + isWarning: true, + path: model.path ?? UIElementPath("") + ) + } + } @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -321,6 +335,8 @@ private final class DecoratingView: UIControl, BlockViewProtocol, VisibleBoundsT } blurView?.frame = bounds + + checkTouchableArea() guard let view = childView else { return } diff --git a/client/ios/LayoutKit/LayoutKit/UI/Blocks/RenderingDelegate.swift b/client/ios/LayoutKit/LayoutKit/UI/Blocks/RenderingDelegate.swift index daefb858b..bae441495 100644 --- a/client/ios/LayoutKit/LayoutKit/UI/Blocks/RenderingDelegate.swift +++ b/client/ios/LayoutKit/LayoutKit/UI/Blocks/RenderingDelegate.swift @@ -7,6 +7,7 @@ public protocol RenderingDelegate: AnyObject { func mapView(_ view: BlockView, to id: BlockViewID) func tooltipAnchorViewAdded(anchorView: TooltipAnchorView) func tooltipAnchorViewRemoved(anchorView: TooltipAnchorView) + func reportRenderingError(message: String, isWarning: Bool, path: UIElementPath) } public typealias BlockViewID = Tagged @@ -18,6 +19,10 @@ public protocol DivViewMetaProviding: AnyObject { public protocol TooltipAnchorView: ViewType { var tooltips: [BlockTooltip] { get } } + +extension RenderingDelegate { + public func reportRenderingError(message: String, isWarning: Bool, path: UIElementPath) { } +} #else public protocol RenderingDelegate {} public typealias BlockViewID = Tagged diff --git a/client/ios/LayoutKit/LayoutKit/UI/Blocks/StateBlock+UIViewRenderableBlock.swift b/client/ios/LayoutKit/LayoutKit/UI/Blocks/StateBlock+UIViewRenderableBlock.swift index f45d5f20c..f3c100856 100644 --- a/client/ios/LayoutKit/LayoutKit/UI/Blocks/StateBlock+UIViewRenderableBlock.swift +++ b/client/ios/LayoutKit/LayoutKit/UI/Blocks/StateBlock+UIViewRenderableBlock.swift @@ -68,6 +68,10 @@ private final class SubviewStorage: RenderingDelegate { func tooltipAnchorViewRemoved(anchorView: TooltipAnchorView) { wrappedRenderingDelegate?.tooltipAnchorViewRemoved(anchorView: anchorView) } + + func reportRenderingError(message: String, isWarning: Bool, path: UIElementPath) { + wrappedRenderingDelegate?.reportRenderingError(message: message, isWarning: isWarning, path: path) + } func getView(_ id: BlockViewID) -> DetachableAnimationBlockView? { views.first { $0.id == id }?.view