mirror of
https://github.com/divkit/divkit.git
synced 2026-06-06 20:07:59 +00:00
419 lines
14 KiB
Swift
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
|