Files
2025-10-30 13:41:29 +03:00

419 lines
14 KiB
Swift

#if os(iOS)
import LayoutKit
import UIKit
import VGSL
/// ``DivView`` is a view that allows you to draw the layout for `DivKit`.
/// It is able to handle actions, switch states correctly, as well as update the layout if
/// necessary.
///
/// ## Example
/// You can find examples of div cards in the
/// [samples](https://github.com/divkit/divkit/tree/main/test_data/samples) or
/// [testing](https://github.com/divkit/divkit/tree/main/test_data/regression_test_data) sections of
/// the demo app.
public final class DivView: VisibleBoundsTrackingView {
/// Returns the intrinsic content size of the ``DivView``.
///
/// - Returns: The calculated intrinsic content size.
public override var intrinsicContentSize: CGSize {
guard let cardSize else {
return CGSize(width: DivView.noIntrinsicMetric, height: DivView.noIntrinsicMetric)
}
let width: CGFloat
switch cardSize.width {
case .matchParent:
width = bounds.width == 0 ? DivView.noIntrinsicMetric : bounds.width
case let .desired(value):
width = value
case .dependsOnOtherDimensionSize:
assertionFailure("Width depends on other dimension size")
width = DivView.noIntrinsicMetric
}
let height: CGFloat = switch cardSize.height {
case .matchParent:
bounds.height == 0 ? DivView.noIntrinsicMetric : bounds.height
case let .desired(value):
value
case let .dependsOnOtherDimensionSize(heightForWidth):
heightForWidth(width)
}
return CGSize(width: width, height: height)
}
private let divKitComponents: DivKitComponents
private let preloader: DivViewPreloader
private var blockSubscription: Disposable?
private var shouldRecalculateVisibilitySubscription: Disposable?
private var shouldRecalculateVisibility = true
private var shouldTrackFocusChanges = true
private var blockProvider: DivBlockProvider? {
didSet {
onVisibleBoundsChanged(to: bounds)
}
}
private var blockView: BlockView? {
didSet {
if blockView !== oldValue {
oldValue?.removeFromSuperview()
blockView.flatMap(addSubview(_:))
}
if blockView != nil {
shouldRecalculateVisibility = true
}
}
}
private let boundsTracker = BoundsTracker()
/// Returns ``DivCardSize`` of the ``DivView``.
///
/// - Returns: The calculated ``DivCardSize``.
public var cardSize: DivViewSize? {
blockProvider?.cardSize
}
/// Initializes a new `DivView` instance.
///
/// - Parameters:
/// - divKitComponents: The ``DivKitComponents`` instance used for configuration and handling of
/// the DivView.
public init(
divKitComponents: DivKitComponents,
divViewPreloader: DivViewPreloader? = nil
) {
self.divKitComponents = divKitComponents
preloader = divViewPreloader ?? DivViewPreloader(divKitComponents: divKitComponents)
super.init(frame: .zero)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tapGestureRecognizer.delegate = self
tapGestureRecognizer.cancelsTouchesInView = false
addGestureRecognizer(tapGestureRecognizer)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func layoutSubviews() {
super.layoutSubviews()
guard let blockView else { return }
blockView.frame = bounds
blockView.layoutIfNeeded()
if shouldRecalculateVisibility {
blockView.onVisibleBoundsChanged(
from: boundsTracker.previousBounds,
to: boundsTracker.lastVisibleBounds
)
shouldRecalculateVisibility = false
boundsTracker.updatePreviousBounds()
}
}
public override func layoutSublayers(of layer: CALayer) {
super.layoutSublayers(of: layer)
invalidateIntrinsicContentSizeIfBoundsChanged()
}
public override func removeFromSuperview() {
super.removeFromSuperview()
blockView?.onVisibleBoundsChanged(
from: boundsTracker.lastVisibleBounds,
to: .zero
)
}
public override func sizeThatFits(_ size: CGSize) -> CGSize {
guard let cardSize else { return .zero }
return cardSize.sizeFor(parentViewSize: size)
}
/// Sets the source of the ``DivView`` and updates the layout.
/// - Parameters:
/// - source: The source of the ``DivView``, specified using `JSON` data, a `Data` object, or
/// ``DivData``.
/// - debugParams: Optional debug configurations for the ``DivView``.
/// - shouldResetPreviousCardData: Specifies whether to clear the data of the previous card when
/// updating the ``DivView`` with new content.
public func setSource(
_ source: DivViewSource,
debugParams: DebugParams = DebugParams(),
shouldResetPreviousCardData: Bool = false
) async {
if shouldResetPreviousCardData, let blockProvider {
divKitComponents.reset(cardId: blockProvider.cardId)
}
blockProvider = preloader.blockProvider(for: source.id.cardId)
blockSubscription = blockProvider?.$block.currentAndNewValues.addObserver { [weak self] in
self?.update(block: $0)
}
shouldRecalculateVisibilitySubscription = blockProvider?
.$shouldRecalculateVisibility.currentAndNewValues.addObserver { [weak self] in
self?.shouldRecalculateVisibility = $0
}
preloader.setSourceTask?.cancel()
await preloader.setSource(source, debugParams: debugParams)
}
/// Sets the source of the ``DivView`` and updates the layout.
/// - Parameters:
/// - source: The source of the ``DivView``, specified using `JSON` data, a `Data` object, or
/// ``DivData``.
/// - debugParams: Optional debug configurations for the ``DivView``.
/// - shouldResetPreviousCardData: Specifies whether to clear the data of the previous card when
/// updating the ``DivView`` with new content.
@_spi(Legacy)
public func setSource(
_ source: DivViewSource,
debugParams: DebugParams = DebugParams(),
shouldResetPreviousCardData: Bool = false
) {
if shouldResetPreviousCardData, let blockProvider {
divKitComponents.reset(cardId: blockProvider.cardId)
}
blockProvider = preloader.blockProvider(for: source.id.cardId)
blockSubscription = blockProvider?.$block.currentAndNewValues.addObserver { [weak self] in
self?.update(block: $0)
}
shouldRecalculateVisibilitySubscription = blockProvider?
.$shouldRecalculateVisibility.currentAndNewValues.addObserver { [weak self] in
self?.shouldRecalculateVisibility = $0
}
preloader.setSource(source, debugParams: debugParams)
}
/// Sets the source of the ``DivView`` and updates the layout.
/// - Parameters:
/// - cardId: ID of the card to show. (The card will not be shown if it has not been previously
/// uploaded to ``DivViewPreloader``)
/// - shouldResetPreviousCardData: Specifies whether to clear the data of the previous card when
/// updating the ``DivView`` with new content.
public func showCardId(_ cardId: DivCardID, shouldResetPreviousCardData: Bool = false) {
if shouldResetPreviousCardData, let blockProvider {
divKitComponents.reset(cardId: blockProvider.cardId)
}
blockProvider = preloader.blockProvider(for: cardId)
blockSubscription = blockProvider?.$block.currentAndNewValues.addObserver { [weak self] in
self?.update(block: $0)
}
shouldRecalculateVisibilitySubscription = blockProvider?
.$shouldRecalculateVisibility.currentAndNewValues.addObserver { [weak self] in
self?.shouldRecalculateVisibility = $0
}
}
/// Sets the parent scroll view for the DivView.
/// - Parameter parentScrollView: The parent scroll view to set.
/// This is used when you require a sub-scroll in a `DivInput`.
public func setParentScrollView(_ parentScrollView: ScrollView) {
blockProvider?.parentScrollView = parentScrollView
}
/// Applies the patch to the card with id.
/// - Parameters:
/// - patch: `DivPatch` that should be applied to the card.
/// - cardId: ID of the card to which the patch should be applied.
public func applyPatch(_ patch: DivPatch, cardId: DivCardID) {
blockProvider?.update(reasons: [.patch(cardId, patch)])
}
/// Adds an observer to listen for ``DivView`` estimated size changes.
///
/// - Parameters:
/// - `onCardSizeChanged`: A closure that gets invoked whenever a ``DivView`` estimated size
/// changes.
///
/// - Returns: A `Disposable` which can be used to unregister the observer when it's no longer
/// needed.
public func addObserver(_ onCardSizeChanged: @escaping (DivViewSize) -> Void) -> Disposable {
preloader.changeEvents.addObserver { [weak self] in
guard $0.cardId == self?.blockProvider?.cardId else { return }
onCardSizeChanged($0.estimatedSize)
}
}
/// Notifies the DivView about changes in its visible bounds.
/// - Parameters:
/// - to: The new bounds rectangle.
public func onVisibleBoundsChanged(to: CGRect) {
guard boundsTracker.append(to) else { return }
shouldRecalculateVisibility = true
if window == nil {
forceLayout()
} else {
setNeedsLayout()
}
}
/// Use ``onVisibleBoundsChanged(to:)`` instead.
public func onVisibleBoundsChanged(from _: CGRect, to: CGRect) {
onVisibleBoundsChanged(to: to)
}
private func update(block: Block) {
let renderingDelegate: RenderingDelegate? = if blockProvider?.id != nil,
let divCardId = blockProvider?.cardId {
divKitComponents.renderingDelegate(for: divCardId)
} else {
nil
}
if let blockView, block.canConfigureBlockView(blockView) {
block.configureBlockView(
blockView,
observer: self,
overscrollDelegate: nil,
renderingDelegate: renderingDelegate
)
} else {
blockView = block.makeBlockView(
observer: self,
renderingDelegate: renderingDelegate
)
}
invalidateIntrinsicContentSize()
setNeedsLayout()
}
private func invalidateIntrinsicContentSizeIfBoundsChanged() {
let oldBounds = boundsTracker.previousBounds
if oldBounds.width != bounds.width, blockProvider?.cardSize?.width == .matchParent ||
oldBounds.height != bounds.height, blockProvider?.cardSize?.height == .matchParent {
invalidateIntrinsicContentSize()
}
}
@objc private func handleTap() {
clearFocus()
}
}
extension DivView: ElementStateObserver {
public func elementStateChanged(_ state: ElementState, forPath path: UIElementPath) {
divKitComponents.blockStateStorage.elementStateChanged(state, forPath: path)
blockProvider?.update(withStates: divKitComponents.blockStateStorage.states)
}
public func focusedElementChanged(isFocused: Bool, forPath path: UIElementPath) {
guard shouldTrackFocusChanges else { return }
divKitComponents.blockStateStorage.focusedElementChanged(isFocused: isFocused, forPath: path)
blockProvider?.update(path: path, isFocused: isFocused)
}
public func clearFocus() {
let blockStateStorage = divKitComponents.blockStateStorage
let focusedElement = blockStateStorage.getFocusedElement()
guard focusedElement != nil else {
return
}
blockStateStorage.clearFocus()
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
public func setFocusTrackingEnabled(_ isEnabled: Bool) {
shouldTrackFocusChanges = isEnabled
}
}
extension DivView: UIActionEventPerforming {
public func perform(uiActionEvent event: UIActionEvent, from sender: AnyObject) {
switch event.payload {
case .empty:
break
case let .url(url):
divKitComponents.urlHandler.handle(url, sender: self)
case let .menu(menu):
nearestViewController?.showMenu(menu, actionPerformer: self)
case let .divAction(params):
divKitComponents.actionHandler.handle(params: params, sender: sender)
}
}
}
extension DivView {
public override var accessibilityElements: [Any]? {
get {
guard let elements = self.blockProvider?.accessibilityElementsStorage?
.getAccessibilityElements(from: self),
!elements.isEmpty else {
return super.accessibilityElements
}
return elements
}
set {}
}
private class BoundsTracker {
private(set) var lastVisibleBounds = CGRect.zero
private(set) var previousBounds = CGRect.zero
func append(_ bounds: CGRect) -> Bool {
if lastVisibleBounds != bounds {
previousBounds = lastVisibleBounds
lastVisibleBounds = bounds
return true
}
return false
}
func updatePreviousBounds() {
previousBounds = lastVisibleBounds
}
}
}
extension UIResponder {
fileprivate var nearestViewController: UIViewController? {
next as? UIViewController ?? next?.nearestViewController
}
}
extension UIViewController {
fileprivate func showMenu(
_ menu: Menu,
actionPerformer: UIActionEventPerforming
) {
let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
for item in menu.items {
let action = UIAlertAction(title: item.text, style: .default) { _ in
let events = item.actions.map {
UIActionEvent(uiAction: $0, originalSender: self)
}
actionPerformer.perform(uiActionEvents: events, from: self)
}
alert.addAction(action)
}
present(alert, animated: true)
}
}
extension DivView: UIGestureRecognizerDelegate {
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
guard let view = gestureRecognizer.view,
let otherView = otherGestureRecognizer.view else { return false }
return view.isDescendant(of: otherView)
}
}
#endif