add visible bounds caching by UIElementPath

This commit is contained in:
chris-benua
2023-08-03 11:34:50 +03:00
parent 82784a9c77
commit 06d6c5cf0e
35 changed files with 526 additions and 53 deletions
@@ -18,6 +18,10 @@
4391F7C32A77DB9B006D8818 /* DivViewController+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4391F7C22A77DB9B006D8818 /* DivViewController+SwiftUI.swift */; };
52184FD8296C68E400ECC0E9 /* DivKitExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 52184FD7296C68E400ECC0E9 /* DivKitExtensions */; };
5219194D297B3974007DD5F4 /* RunLoopCardUpdateAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5219194C297B3974007DD5F4 /* RunLoopCardUpdateAggregatorTests.swift */; };
52907F662A71199B00518F50 /* DivLastVisibleBoundsCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52907F652A71199B00518F50 /* DivLastVisibleBoundsCacheTests.swift */; };
52CD80672A7122AA00B828B0 /* DivBaseExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52CD80662A7122AA00B828B0 /* DivBaseExtensionsTests.swift */; };
52CD80692A7159C200B828B0 /* DivStateExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52CD80682A7159C200B828B0 /* DivStateExtensionsTests.swift */; };
52CD806B2A7172B400B828B0 /* XCTestCase+ViewVisibilityCallCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52CD806A2A7172B400B828B0 /* XCTestCase+ViewVisibilityCallCount.swift */; };
52DED04F29F7349000D64519 /* DivKit in Frameworks */ = {isa = PBXBuildFile; productRef = 52DED04E29F7349000D64519 /* DivKit */; };
52DED05129F7349000D64519 /* DivKitExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 52DED05029F7349000D64519 /* DivKitExtensions */; };
52DED05329F734B900D64519 /* ShimmerStyleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DED03C29F6E3D500D64519 /* ShimmerStyleTests.swift */; };
@@ -260,6 +264,10 @@
437B19D329C8A73300A4C467 /* MaskValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaskValidatorTests.swift; sourceTree = "<group>"; };
4391F7C22A77DB9B006D8818 /* DivViewController+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DivViewController+SwiftUI.swift"; sourceTree = "<group>"; };
5219194C297B3974007DD5F4 /* RunLoopCardUpdateAggregatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RunLoopCardUpdateAggregatorTests.swift; sourceTree = "<group>"; };
52907F652A71199B00518F50 /* DivLastVisibleBoundsCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DivLastVisibleBoundsCacheTests.swift; sourceTree = "<group>"; };
52CD80662A7122AA00B828B0 /* DivBaseExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DivBaseExtensionsTests.swift; sourceTree = "<group>"; };
52CD80682A7159C200B828B0 /* DivStateExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DivStateExtensionsTests.swift; sourceTree = "<group>"; };
52CD806A2A7172B400B828B0 /* XCTestCase+ViewVisibilityCallCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+ViewVisibilityCallCount.swift"; sourceTree = "<group>"; };
52DED03C29F6E3D500D64519 /* ShimmerStyleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShimmerStyleTests.swift; sourceTree = "<group>"; };
52DED04729F7346900D64519 /* DivKitExtensionsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DivKitExtensionsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
52EEC66D293E64C90030FB11 /* DivBlockModelingContextErrorsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DivBlockModelingContextErrorsTests.swift; sourceTree = "<group>"; };
@@ -843,6 +851,7 @@
8CB960E528883B8E00D16E47 /* Platform.swift */,
5219194C297B3974007DD5F4 /* RunLoopCardUpdateAggregatorTests.swift */,
8C4DC3E72892946F000C0963 /* RuntimeTestCase.swift */,
52907F652A71199B00518F50 /* DivLastVisibleBoundsCacheTests.swift */,
);
path = DivKitTests;
sourceTree = "<group>";
@@ -872,6 +881,9 @@
8C7B1B252865C01C0036EF4C /* DivTabsExtensionsTests.swift */,
8C7B1B282865C01C0036EF4C /* DivTextExtensionsTests.swift */,
8C1CF625286B561F0016D0A1 /* Expected.swift */,
52CD80662A7122AA00B828B0 /* DivBaseExtensionsTests.swift */,
52CD80682A7159C200B828B0 /* DivStateExtensionsTests.swift */,
52CD806A2A7172B400B828B0 /* XCTestCase+ViewVisibilityCallCount.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -1505,6 +1517,7 @@
8C23F41529DDB3810069F3F7 /* EntityWithArrayOfEnumsTemplate.swift in Sources */,
8C23F43229DDB3810069F3F7 /* EntityWithStringArrayPropertyTemplate.swift in Sources */,
8C23F44829DDB3810069F3F7 /* StringEnumPropertyTests.swift in Sources */,
52CD806B2A7172B400B828B0 /* XCTestCase+ViewVisibilityCallCount.swift in Sources */,
8C23F43329DDB3810069F3F7 /* EntityWithStringEnumProperty.swift in Sources */,
8C23F44729DDB3810069F3F7 /* StrictArrayTests.swift in Sources */,
8C23F43B29DDB3810069F3F7 /* ArrayTests.swift in Sources */,
@@ -1513,6 +1526,7 @@
8C1CF626286B561F0016D0A1 /* Expected.swift in Sources */,
7035529E28D4881300E237CF /* DivDeserializationErrorsTests.swift in Sources */,
E945FC46293E1CA80066C3EA /* Utils.swift in Sources */,
52907F662A71199B00518F50 /* DivLastVisibleBoundsCacheTests.swift in Sources */,
8C23F41729DDB3810069F3F7 /* EntityWithArrayOfExpressionsTemplate.swift in Sources */,
8C23F41D29DDB3810069F3F7 /* EntityWithComplexProperty.swift in Sources */,
8C7B1CA62865C01D0036EF4C /* TemplatesTests.swift in Sources */,
@@ -1529,6 +1543,7 @@
8C7B1BF32865C01C0036EF4C /* DivTabsExtensionsTests.swift in Sources */,
8C23F44429DDB3810069F3F7 /* OptionalStringEnumPropertyTests.swift in Sources */,
891672372A5F6F9300121131 /* DivPersistentValuesStorageTests.swift in Sources */,
52CD80672A7122AA00B828B0 /* DivBaseExtensionsTests.swift in Sources */,
8C23F43F29DDB3810069F3F7 /* EntityProtocol.swift in Sources */,
52EEC66E293E64C90030FB11 /* DivBlockModelingContextErrorsTests.swift in Sources */,
8C23F42729DDB3810069F3F7 /* EntityWithOptionalStringEnumProperty.swift in Sources */,
@@ -1559,6 +1574,7 @@
8C4DC3E82892946F000C0963 /* RuntimeTestCase.swift in Sources */,
8C23F42D29DDB3810069F3F7 /* EntityWithSimpleProperties.swift in Sources */,
8C23F42B29DDB3810069F3F7 /* EntityWithRequiredProperty.swift in Sources */,
52CD80692A7159C200B828B0 /* DivStateExtensionsTests.swift in Sources */,
8C23F44329DDB3810069F3F7 /* OptionalPropertyTests.swift in Sources */,
8C1DEBCE2A2F8277009237F1 /* DivStateManagementTests.swift in Sources */,
8C7B1BFA2865C01C0036EF4C /* DivDataExtensionsTests.swift in Sources */,
@@ -20,6 +20,7 @@ public struct DivBlockModelingContext {
public var stateManager: DivStateManager
public let blockStateStorage: DivBlockStateStorage
public let visibilityCounter: DivVisibilityCounting
public let lastVisibleBoundsCache: DivLastVisibleBoundsCache
public let imageHolderFactory: ImageHolderFactory
public let highPriorityImageHolderFactory: ImageHolderFactory?
public let divCustomBlockFactory: DivCustomBlockFactory
@@ -30,6 +31,7 @@ public struct DivBlockModelingContext {
private let variables: DivVariables
public let layoutDirection: UserInterfaceLayoutDirection
public let debugParams: DebugParams
public let scheduler: Scheduling
public let playerFactory: PlayerFactory?
public var childrenA11yDescription: String?
public weak var parentScrollView: ScrollView?
@@ -61,6 +63,7 @@ public struct DivBlockModelingContext {
stateManager: DivStateManager,
blockStateStorage: DivBlockStateStorage = DivBlockStateStorage(),
visibilityCounter: DivVisibilityCounting = DivVisibilityCounter(),
lastVisibleBoundsCache: DivLastVisibleBoundsCache = DivLastVisibleBoundsCache(),
imageHolderFactory: ImageHolderFactory,
highPriorityImageHolderFactory: ImageHolderFactory? = nil,
divCustomBlockFactory: DivCustomBlockFactory = EmptyDivCustomBlockFactory(),
@@ -71,6 +74,7 @@ public struct DivBlockModelingContext {
variables: DivVariables = [:],
playerFactory: PlayerFactory? = nil,
debugParams: DebugParams = DebugParams(),
scheduler: Scheduling? = nil,
childrenA11yDescription: String? = nil,
parentScrollView: ScrollView? = nil,
errorsStorage: DivErrorsStorage = DivErrorsStorage(errors: []),
@@ -85,6 +89,7 @@ public struct DivBlockModelingContext {
self.stateManager = stateManager
self.blockStateStorage = blockStateStorage
self.visibilityCounter = visibilityCounter
self.lastVisibleBoundsCache = lastVisibleBoundsCache
self.imageHolderFactory = imageHolderFactory
self.highPriorityImageHolderFactory = highPriorityImageHolderFactory
self.divCustomBlockFactory = divCustomBlockFactory
@@ -93,6 +98,7 @@ public struct DivBlockModelingContext {
self.variables = variables
self.playerFactory = playerFactory
self.debugParams = debugParams
self.scheduler = scheduler ?? TimerScheduler()
self.childrenA11yDescription = childrenA11yDescription
self.parentScrollView = parentScrollView
self.errorsStorage = errorsStorage
+2
View File
@@ -33,6 +33,7 @@ public final class DivKitComponents {
public let urlHandler: DivUrlHandler
public let variablesStorage: DivVariablesStorage
public let visibilityCounter = DivVisibilityCounter()
public let lastVisibleBoundsCache = DivLastVisibleBoundsCache()
public var updateCardSignal: Signal<[DivActionURLHandler.UpdateReason]> {
updateCardPipe.signal
}
@@ -227,6 +228,7 @@ public final class DivKitComponents {
stateManager: stateManagement.getStateManagerForCard(cardId: cardId),
blockStateStorage: blockStateStorage,
visibilityCounter: visibilityCounter,
lastVisibleBoundsCache: lastVisibleBoundsCache,
imageHolderFactory: imageHolderFactory
.withInMemoryCache(cachedImageHolders: cachedImageHolders),
divCustomBlockFactory: divCustomBlockFactory,
@@ -0,0 +1,40 @@
import CoreGraphics
import LayoutKit
import CommonCorePublic
public final class DivLastVisibleBoundsCache {
private let rwLock = RWLock()
private var storage: [UIElementPath: CGRect] = [:]
public init() {}
public func lastVisibleBounds(for path: UIElementPath) -> CGRect {
rwLock.read {
storage[path] ?? .zero
}
}
public func updateLastVisibleBounds(for path: UIElementPath, bounds: CGRect) {
rwLock.write {
storage[path] = bounds
}
}
public func dropVisibleBounds(forMatchingPrefix prefix: UIElementPath) {
let prefix = prefix.description + "/"
rwLock.write {
storage.forEach {
if ($0.key.description + "/").starts(with: prefix) {
storage[$0.key] = nil
}
}
}
}
public func reset() {
rwLock.write {
storage.removeAll()
}
}
}
@@ -21,6 +21,7 @@ extension DivBase {
let visibility = resolveVisibility(expressionResolver)
if visibility == .gone {
context.lastVisibleBoundsCache.dropVisibleBounds(forMatchingPrefix: context.parentPath)
context.stateManager.setBlockVisibility(statePath: statePath, div: self, isVisible: false)
return EmptyBlock.zeroSized
}
@@ -39,6 +40,7 @@ extension DivBase {
let externalInsets = margins.makeEdgeInsets(context: context)
if visibility == .invisible {
context.lastVisibleBoundsCache.dropVisibleBounds(forMatchingPrefix: context.parentPath)
context.stateManager.setBlockVisibility(statePath: statePath, div: self, isVisible: false)
block = applyExtensionHandlersAfterBaseProperties(
to: block.addingEdgeInsets(externalInsets),
@@ -72,6 +74,16 @@ extension DivBase {
border: border.makeBlockBorder(with: expressionResolver),
shadow: border.makeBlockShadow(with: expressionResolver),
visibilityActions: visibilityActions.isEmpty ? nil : visibilityActions,
lastVisibleBounds: visibilityActions.isEmpty ? nil : Property<CGRect>(
getter: { context.lastVisibleBoundsCache.lastVisibleBounds(for: context.parentPath) },
setter: {
context.lastVisibleBoundsCache.updateLastVisibleBounds(
for: context.parentPath,
bounds: $0
)
}
),
scheduler: context.scheduler,
tooltips: try makeTooltips(context: context)
)
.addingTransform(
@@ -11,6 +11,10 @@ extension DivData: DivBlockModeling {
throw DivBlockModelingError("DivData has no states", path: context.parentPath)
}
if let previousRootState = getPreviousRootState(stateManager: stateManager) {
context.lastVisibleBoundsCache.dropVisibleBounds(forMatchingPrefix: context.parentPath + previousRootState.rawValue)
}
let stateId = String(state.stateId)
let statePath = DivStatePath(rawValue: UIElementPath(stateId))
let div = state.div
@@ -31,7 +35,6 @@ extension DivData: DivBlockModeling {
)
return block
.addingStateBlock(
stateId: stateId,
ids: stateManager.getVisibleIds(statePath: statePath)
)
.addingDebugInfo(context: divContext)
@@ -53,6 +56,22 @@ extension DivData: DivBlockModeling {
context.addError(level: .error, message: "DivData.State not found: \(stateId)")
return states.first
}
private func getPreviousRootState(stateManager: DivStateManager) -> DivStateID? {
guard let item = stateManager.get(stateBlockPath: DivData.rootPath) else {
return nil
}
switch item.previousState {
case .empty, .initial:
return nil
case let .withID(id):
if item.currentStateID != id {
return id
}
return nil
}
}
}
extension DivData {
@@ -46,6 +46,8 @@ extension DivState: DivBlockModeling {
let previousState = getPreviousState(stateManagerItem: stateManagerItem),
previousState.stateId != activeStateId,
let previousDiv = previousState.div {
// state changed -> drop visibility cache for all children
context.lastVisibleBoundsCache.dropVisibleBounds(forMatchingPrefix: context.parentPath)
previousBlock = try previousDiv.value.makeBlock(
context: context.makeContextForState(
id: id,
@@ -99,7 +101,6 @@ extension DivState: DivBlockModeling {
),
]
).addingStateBlock(
stateId: activeStateId,
ids: stateManager.getVisibleIds(statePath: activeStatePath)
)
}
+4 -2
View File
@@ -1,7 +1,7 @@
import XCTest
@testable import DivKit
import CommonCorePublic
@testable import DivKit
import LayoutKit
import NetworkingPublic
import Serialization
@@ -48,7 +48,8 @@ extension DivBlockModelingContext {
static let `default` = DivBlockModelingContext()
init(
blockStateStorage: DivBlockStateStorage = DivBlockStateStorage()
blockStateStorage: DivBlockStateStorage = DivBlockStateStorage(),
scheduler: Scheduling? = nil
) {
self.init(
cardId: DivKitTests.cardId,
@@ -56,6 +57,7 @@ extension DivBlockModelingContext {
stateManager: DivStateManager(),
blockStateStorage: blockStateStorage,
imageHolderFactory: ImageHolderFactory(make: { _, _ in FakeImageHolder() }),
scheduler: scheduler,
persistentValuesStorage: DivPersistentValuesStorage()
)
}
@@ -0,0 +1,50 @@
import DivKit
import LayoutKit
import XCTest
final class DivLastVisibleBoundsCacheTests: XCTestCase {
let cache = DivLastVisibleBoundsCache()
func test_WhenCacheIsEmpty_RetrievesZeroFromCache() {
XCTAssertEqual(cache.lastVisibleBounds(for: "path"), .zero)
}
func test_WhenUpdateLastVisibleBounds_RetrievesUpdatedValueFromCache() {
let rect = CGRect(origin: CGPoint(x: 10, y: 10), size: CGSize(squareDimension: 10))
cache.updateLastVisibleBounds(for: "path", bounds: rect)
XCTAssertEqual(cache.lastVisibleBounds(for: "path"), rect)
}
func test_WhenReset_RetrievesZeroFromCache() {
let rect = CGRect(origin: CGPoint(x: 10, y: 10), size: CGSize(squareDimension: 10))
cache.updateLastVisibleBounds(for: "path", bounds: rect)
cache.reset()
XCTAssertEqual(cache.lastVisibleBounds(for: "path"), .zero)
}
func test_WhenDropVisibleBoundsWithMatchingPrefix_ClearsAllChildPaths() {
let rect1 = CGRect(origin: CGPoint(x: 10, y: 10), size: CGSize(squareDimension: 10))
let rect2 = CGRect(origin: .zero, size: CGSize(squareDimension: 10))
let rect3 = CGRect(origin: .zero, size: CGSize(squareDimension: 20))
let parentPath: UIElementPath = "path"
let parentPath2: UIElementPath = "path2"
let childPath: UIElementPath = parentPath + "child"
let nestedChildPath: UIElementPath = childPath + "nestedChild"
cache.updateLastVisibleBounds(for: parentPath, bounds: rect1)
cache.updateLastVisibleBounds(for: parentPath2, bounds: rect1)
cache.updateLastVisibleBounds(for: childPath, bounds: rect2)
cache.updateLastVisibleBounds(for: nestedChildPath, bounds: rect3)
cache.dropVisibleBounds(forMatchingPrefix: parentPath)
XCTAssertEqual(cache.lastVisibleBounds(for: childPath), .zero)
XCTAssertEqual(cache.lastVisibleBounds(for: nestedChildPath), .zero)
XCTAssertEqual(cache.lastVisibleBounds(for: parentPath2), rect1)
XCTAssertEqual(cache.lastVisibleBounds(for: parentPath), .zero)
}
}
@@ -0,0 +1,86 @@
@testable import LayoutKit
import XCTest
import CommonCorePublic
import DivKit
import NetworkingPublic
final class DivBaseExtensionsTests: XCTestCase {
private let timer = TestTimerScheduler()
func test_WhenCreatesBlockAfterItBeingGone_ReportsVisibility() throws {
try expectVisibilityActionsToRun(
forVisibleBlockFile: "div-text-visibility-actions-visible",
invisibleBlockFile: "div-text-visibility-actions-gone"
)
}
func test_WhenCreatesBlockAfterItBeingInvisible_ReportsVisibility() throws {
try expectVisibilityActionsToRun(
forVisibleBlockFile: "div-text-visibility-actions-visible",
invisibleBlockFile: "div-text-visibility-actions-invisible"
)
}
func test_WhenReusesBlockWithAnotherDivCardID_ExpectToCancelPreviousBlockTimers() throws {
let context = DivBlockModelingContext(scheduler: timer)
let firstBlock = try makeBlock(fromFile: "div-text-visibility-actions-visible", context: context)
// trigger visibility actions for first time
let rect = CGRect(origin: .zero, size: CGSize(squareDimension: 20))
let view = firstBlock.makeBlockView()
view.frame = rect
view.layoutIfNeeded()
view.onVisibleBoundsChanged(from: rect, to: rect)
let oldTimers = timer.timers
let secondBlock = try makeBlock(
fromFile: "div-text-visibility-actions-visible",
context: DivBlockModelingContext(
cardId: "another_cardId",
stateManager: DivStateManager(),
imageHolderFactory: ImageHolderFactory(make: { _, _ in FakeImageHolder() })
)
)
let view2 = secondBlock.reuse(view, observer: nil, overscrollDelegate: nil, renderingDelegate: nil, superview: nil)
view2.frame = rect
view2.layoutIfNeeded()
view.onVisibleBoundsChanged(from: rect, to: rect)
XCTAssertEqual(oldTimers.allSatisfy { !$0.isValid }, true)
}
private func expectVisibilityActionsToRun(
forVisibleBlockFile file: String,
invisibleBlockFile: String
) throws {
let context = DivBlockModelingContext(scheduler: timer)
let blockVisibleFirst = try makeBlock(fromFile: file, context: context)
// trigger visibility actions for first time
let rect = CGRect(origin: .zero, size: CGSize(squareDimension: 20))
let view = blockVisibleFirst.makeBlockView()
XCTAssertEqual(getViewVisibilityCallCount(view: view, rect: rect, timerScheduler: timer), 1)
// expect to drop lastVisibleBounds
let _ = try makeBlock(fromFile: invisibleBlockFile, context: context)
let view2 = blockVisibleFirst.makeBlockView()
XCTAssertEqual(getViewVisibilityCallCount(view: view2, rect: rect, timerScheduler: timer), 1)
}
}
private func makeBlock(
fromFile filename: String,
context: DivBlockModelingContext = .default
) throws -> Block {
try DivTextTemplate.make(
fromFile: filename,
subdirectory: "div-base",
context: context
)
}
@@ -60,6 +60,26 @@ final class DivDataExtensionsTests: XCTestCase {
let expectedPath = UIElementPath.root + "0" + DivGallery.type
XCTAssertEqual(galleryBlock?.model.path, expectedPath)
}
func test_WhenStateChanges_ReportsVisibilityForNewState() throws {
let timerScheduler = TestTimerScheduler()
let context = DivBlockModelingContext(scheduler: timerScheduler)
for rootStateId in [nil, "1", "0", "1"] {
if let rootStateId {
context.stateManager.setStateWithHistory(path: DivData.rootPath, stateID: DivStateID(rawValue: rootStateId))
}
let block = try makeBlock(fromFile: "root_states_visibility", context: context)
let rect = CGRect(
origin: .zero,
size: CGSize(width: 100, height: block.intrinsicContentHeight(forWidth: 100))
)
let view = block.makeBlockView()
XCTAssertEqual(getViewVisibilityCallCount(view: view, rect: rect, timerScheduler: timerScheduler), 1)
}
}
}
private let data = makeDivData(
@@ -103,3 +123,15 @@ extension Block {
return self
}
}
private func makeBlock(
fromFile filename: String,
context: DivBlockModelingContext = .default
) throws -> Block {
try DivDataTemplate.make(
fromFile: filename,
subdirectory: "div-data",
context: context
)
}
@@ -0,0 +1,66 @@
@testable import LayoutKit
import XCTest
import CommonCorePublic
import DivKit
final class DivStateExtensionsTests: XCTestCase {
private let timerScheduler = TestTimerScheduler()
func test_WhenVisibilityWasTriggeredAndStateChanges_ReportsVisibilityAgain() throws {
let context = DivBlockModelingContext(scheduler: timerScheduler)
let block = try makeBlock(fromFile: "states_visibility", context: context)
let rect = CGRect(
origin: .zero,
size: CGSize(width: 100, height: block.intrinsicContentHeight(forWidth: 100))
)
let view = block.makeBlockView()
XCTAssertEqual(getViewVisibilityCallCount(view: view, rect: rect, timerScheduler: timerScheduler), 1)
context.stateManager.setState(stateBlockPath: "mystate", stateID: "second")
let block2 = try makeBlock(fromFile: "states_visibility", context: context)
let view2 = block2.reuse(
view,
observer: nil,
overscrollDelegate: nil,
renderingDelegate: nil,
superview: nil
)
XCTAssertEqual(getViewVisibilityCallCount(view: view2, rect: rect, timerScheduler: timerScheduler), 1)
}
func test_WhenVisibilityWasTriggeredAndStateDoesNotChange_DoesNotReportVisibilityAgain() throws {
let context = DivBlockModelingContext(scheduler: timerScheduler)
let block = try makeBlock(fromFile: "states_visibility", context: context)
let rect = CGRect(
origin: .zero,
size: CGSize(width: 100, height: block.intrinsicContentHeight(forWidth: 100))
)
let view = block.makeBlockView()
XCTAssertEqual(getViewVisibilityCallCount(view: view, rect: rect, timerScheduler: timerScheduler), 1)
let block2 = try makeBlock(fromFile: "states_visibility", context: context)
let view2 = block2.reuse(
view,
observer: nil,
overscrollDelegate: nil,
renderingDelegate: nil,
superview: nil
)
XCTAssertEqual(getViewVisibilityCallCount(view: view2, rect: rect, timerScheduler: timerScheduler), 0)
}
}
private func makeBlock(
fromFile filename: String,
context: DivBlockModelingContext = .default
) throws -> Block {
try DivStateTemplate.make(
fromFile: filename,
subdirectory: "div-state",
context: context
)
}
@@ -0,0 +1,28 @@
import LayoutKit
import XCTest
extension XCTestCase {
func getViewVisibilityCallCount(
view: BlockView,
rect: CGRect,
timerScheduler: TestTimerScheduler
) -> Int {
let performer = UIActionEventPerformerMock()
performer.addSubview(view)
view.frame = rect
view.layoutIfNeeded()
view.onVisibleBoundsChanged(from: rect, to: rect)
timerScheduler.timers.forEach { guard $0.isValid else { return }; $0.fire() }
return performer.callCount
}
}
private class UIActionEventPerformerMock: UIView, UIActionEventPerforming {
var callCount: Int = 0
func perform(uiActionEvent _: UIActionEvent, from _: AnyObject) {
callCount += 1
}
}
@@ -27,6 +27,8 @@ extension Block {
longTapActions: LongTapActions? = nil,
analyticsURL: URL? = nil,
visibilityActions: [VisibilityAction]? = nil,
scheduler: Scheduling? = nil,
lastVisibleBounds: Property<CGRect>? = nil,
tooltips: [BlockTooltip]? = nil,
forceWrapping: Bool,
accessibilityElement: AccessibilityElement? = nil
@@ -92,6 +94,8 @@ extension Block {
childAlpha: alpha.map { $0 * block.childAlpha },
blurEffect: blurEffect ?? block.blurEffect,
visibilityActions: visibilityActions ?? block.visibilityActions,
scheduler: scheduler ?? block.scheduler,
lastVisibleBounds: lastVisibleBounds ?? block.lastVisibleBounds,
tooltips: [tooltips, block.tooltips].compactMap { $0 }.flatMap { $0 },
accessibilityElement: accessibilityElement
)
@@ -116,6 +120,8 @@ extension Block {
childAlpha: alpha ?? DecoratingBlock.defaultChildAlpha,
blurEffect: blurEffect,
visibilityActions: visibilityActions ?? [],
scheduler: scheduler,
lastVisibleBounds: lastVisibleBounds,
tooltips: tooltips ?? [],
accessibilityElement: accessibilityElement
)
@@ -143,6 +149,8 @@ extension Block {
analyticsURL: URL? = nil,
shadow: BlockShadow? = nil,
visibilityActions: [VisibilityAction]? = nil,
lastVisibleBounds: Property<CGRect>? = nil,
scheduler: Scheduling? = nil,
tooltips: [BlockTooltip]? = nil,
forceWrapping: Bool = false,
accessibilityElement: AccessibilityElement? = nil
@@ -160,6 +168,8 @@ extension Block {
longTapActions: longTapActions,
analyticsURL: analyticsURL,
visibilityActions: visibilityActions,
scheduler: scheduler,
lastVisibleBounds: lastVisibleBounds,
tooltips: tooltips,
forceWrapping: forceWrapping,
accessibilityElement: accessibilityElement
@@ -25,6 +25,8 @@ final class DecoratingBlock: WrapperBlock {
let blurEffect: BlurEffect?
let paddings: EdgeInsets
let visibilityActions: [VisibilityAction]
let scheduler: Scheduling?
let lastVisibleBounds: Property<CGRect>?
let tooltips: [BlockTooltip]
let accessibilityElement: AccessibilityElement?
@@ -43,6 +45,8 @@ final class DecoratingBlock: WrapperBlock {
blurEffect: BlurEffect? = nil,
paddings: EdgeInsets = .zero,
visibilityActions: [VisibilityAction] = [],
scheduler: Scheduling? = nil,
lastVisibleBounds: Property<CGRect>? = nil,
tooltips: [BlockTooltip] = [],
accessibilityElement: AccessibilityElement? = nil
) {
@@ -60,6 +64,8 @@ final class DecoratingBlock: WrapperBlock {
self.blurEffect = blurEffect
self.paddings = paddings
self.visibilityActions = visibilityActions
self.scheduler = scheduler
self.lastVisibleBounds = lastVisibleBounds
self.tooltips = tooltips
self.accessibilityElement = accessibilityElement
}
@@ -144,6 +150,8 @@ extension DecoratingBlock {
blurEffect: BlurEffect? = nil,
paddings: EdgeInsets? = nil,
visibilityActions: [VisibilityAction]? = nil,
scheduler: Scheduling? = nil,
lastVisibleBounds: Property<CGRect>? = nil,
tooltips: [BlockTooltip]? = nil,
accessibilityElement: AccessibilityElement? = nil
) -> DecoratingBlock {
@@ -162,6 +170,8 @@ extension DecoratingBlock {
blurEffect: blurEffect ?? self.blurEffect,
paddings: paddings ?? self.paddings,
visibilityActions: visibilityActions ?? self.visibilityActions,
scheduler: scheduler ?? self.scheduler,
lastVisibleBounds: lastVisibleBounds ?? self.lastVisibleBounds,
tooltips: tooltips ?? self.tooltips,
accessibilityElement: accessibilityElement ?? self.accessibilityElement
)
@@ -2,16 +2,13 @@ import CommonCorePublic
public final class StateBlock: WrapperBlock, LayoutCachingDefaultImpl {
public let child: Block
public let stateId: String
public let ids: Set<String>
public init(
child: Block,
stateId: String,
ids: Set<String>
) {
self.child = child
self.stateId = stateId
self.ids = ids
}
@@ -23,14 +20,13 @@ public final class StateBlock: WrapperBlock, LayoutCachingDefaultImpl {
}
public func makeCopy(wrapping child: Block) -> StateBlock {
StateBlock(child: child, stateId: stateId, ids: ids)
StateBlock(child: child, ids: ids)
}
}
extension StateBlock: Equatable {
public static func ==(lhs: StateBlock, rhs: StateBlock) -> Bool {
lhs.child == rhs.child
&& lhs.stateId == rhs.stateId
&& lhs.ids == rhs.ids
}
}
@@ -41,9 +37,8 @@ extension StateBlock: CustomDebugStringConvertible {
extension Block {
public func addingStateBlock(
stateId: String,
ids: Set<String>
) -> Block {
StateBlock(child: self, stateId: stateId, ids: ids)
StateBlock(child: self, ids: ids)
}
}
@@ -38,7 +38,9 @@ extension DecoratingBlock {
blurEffect: blurEffect,
paddings: paddings,
source: Variable { [weak self] in self },
scheduler: scheduler,
visibilityActions: visibilityActions,
lastVisibleBounds: lastVisibleBounds,
tooltips: tooltips,
accessibility: accessibilityElement
)
@@ -101,7 +103,9 @@ private final class DecoratingView: UIControl, BlockViewProtocol, VisibleBoundsT
let blurEffect: BlurEffect?
let paddings: EdgeInsets
let source: Variable<AnyObject?>
let scheduler: Scheduling?
let visibilityActions: [VisibilityAction]
let lastVisibleBounds: Property<CGRect>?
let tooltips: [BlockTooltip]
let accessibility: AccessibilityElement?
@@ -289,6 +293,7 @@ private final class DecoratingView: UIControl, BlockViewProtocol, VisibleBoundsT
guard model != self.model || observer !== self.observer else {
return
}
let oldModel = self.model
if self.model?.tooltips.isEmpty ?? true, !model.tooltips.isEmpty {
renderingDelegate?.tooltipAnchorViewAdded(anchorView: self)
@@ -320,24 +325,28 @@ private final class DecoratingView: UIControl, BlockViewProtocol, VisibleBoundsT
model.actions?
.forEach { applyAccessibility($0.accessibilityElement) }
visibilityActionPerformers = model.visibilityActions.isEmpty
? nil
: VisibilityActionPerformers(
visibilityCheckParams: model.visibilityActions.map { visibilityAction in
VisibilityCheckParam(
requiredDuration: visibilityAction.requiredDuration,
targetPercentage: visibilityAction.targetPercentage,
limiter: visibilityAction.limiter,
action: { [unowned self] in
UIActionEvent(
uiAction: visibilityAction.uiAction,
originalSender: self
).sendFrom(self)
},
type: visibilityAction.actionType
)
}
)
if oldModel?.visibilityActions != model.visibilityActions {
visibilityActionPerformers = model.visibilityActions.isEmpty
? nil
: VisibilityActionPerformers(
visibilityCheckParams: model.visibilityActions.map { visibilityAction in
VisibilityCheckParam(
requiredDuration: visibilityAction.requiredDuration,
targetPercentage: visibilityAction.targetPercentage,
limiter: visibilityAction.limiter,
action: { [unowned self] in
UIActionEvent(
uiAction: visibilityAction.uiAction,
originalSender: self
).sendFrom(self)
},
type: visibilityAction.actionType
)
},
lastVisibleBounds: model.lastVisibleBounds ?? Property(initialValue: .zero),
scheduling: model.scheduler ?? TimerScheduler()
)
}
tapRecognizer.isEnabled = model.shouldHandleTap
doubleTapRecognizer.isEnabled = model.shouldHandleDoubleTap
@@ -19,7 +19,6 @@ extension StateBlock {
) {
(view as! StateBlockView).configure(
child: child,
stateId: stateId,
ids: Set(ids.map { BlockViewID(rawValue: $0) }),
observer: observer,
overscrollDelegate: overscrollDelegate,
@@ -109,7 +108,6 @@ private final class StateBlockView: BlockView {
private var subviewStorage = SubviewStorage(wrappedRenderingDelegate: nil, ids: [])
private var childView: BlockView?
private var stateId: String?
private var isStateChanged = false
var effectiveBackgroundColor: UIColor? { childView?.effectiveBackgroundColor }
@@ -124,17 +122,11 @@ private final class StateBlockView: BlockView {
func configure(
child: Block,
stateId: String,
ids: Set<BlockViewID>,
observer: ElementStateObserver?,
overscrollDelegate: ScrollDelegate?,
renderingDelegate: RenderingDelegate?
) {
if (self.stateId != stateId) {
isStateChanged = true
self.stateId = stateId
}
// remove views with unfinished animations
subviews.forEach {
if $0 !== childView {
@@ -188,15 +180,6 @@ private final class StateBlockView: BlockView {
}
extension StateBlockView: VisibleBoundsTrackingContainer {
func onVisibleBoundsChanged(from: CGRect, to: CGRect) {
if isStateChanged {
isStateChanged = false
passVisibleBoundsChanged(from: .zero, to: to)
} else {
passVisibleBoundsChanged(from: from, to: to)
}
}
var visibleBoundsTrackingSubviews: [VisibleBoundsTrackingView] {
childView.asArray()
}
@@ -22,7 +22,7 @@ final class VisibilityActionPerformer {
limiter: ActionLimiter,
action: @escaping Action,
type: VisibilityActionType,
timerScheduler: Scheduling = TimerScheduler()
timerScheduler: Scheduling
) {
self.requiredDuration = requiredDuration
self.targetPercentage = targetVisibilityPercentage
@@ -1,15 +1,29 @@
import CoreGraphics
import Foundation
import BasePublic
public final class VisibilityActionPerformers {
private let actionPerformers: [VisibilityActionPerformer]
private let lastVisibleBounds: Property<CGRect>
init(visibilityCheckParams: [VisibilityCheckParam]) {
actionPerformers = visibilityCheckParams.map(VisibilityActionPerformer.init)
init(
visibilityCheckParams: [VisibilityCheckParam],
lastVisibleBounds: Property<CGRect>,
scheduling: Scheduling
) {
self.lastVisibleBounds = lastVisibleBounds
actionPerformers = visibilityCheckParams.map {
VisibilityActionPerformer(visibilityCheckParam: $0, scheduling: scheduling)
}
}
func onVisibleBoundsChanged(from: CGRect, to: CGRect, bounds: CGRect) {
let visibleAreaPercentageBefore = bounds.isEmpty ? 0 : Int(from.area * 100 / bounds.area)
func onVisibleBoundsChanged(from _: CGRect, to: CGRect, bounds: CGRect) {
let beforeVisibleBounds = lastVisibleBounds.value
lastVisibleBounds.value = to
let visibleAreaPercentageBefore = bounds
.isEmpty ? 0 : Int(beforeVisibleBounds.area * 100 / bounds.area)
let visibleAreaPercentageAfter = bounds.isEmpty ? 0 : Int(to.area * 100 / bounds.area)
for performer in actionPerformers {
@@ -22,13 +36,14 @@ public final class VisibilityActionPerformers {
}
extension VisibilityActionPerformer {
fileprivate convenience init(visibilityCheckParam: VisibilityCheckParam) {
fileprivate convenience init(visibilityCheckParam: VisibilityCheckParam, scheduling: Scheduling) {
self.init(
requiredDuration: visibilityCheckParam.requiredDuration,
targetVisibilityPercentage: visibilityCheckParam.targetPercentage,
limiter: visibilityCheckParam.limiter,
action: visibilityCheckParam.action,
type: visibilityCheckParam.type
type: visibilityCheckParam.type,
timerScheduler: scheduling
)
}
}
@@ -0,0 +1,11 @@
{
"type": "text",
"text": "text",
"visibility": "gone",
"visibility_actions": [
{
"log_id": "some",
"log_limit": 0
}
]
}
@@ -0,0 +1,12 @@
{
"type": "text",
"text": "text",
"visibility": "invisible",
"visibility_actions": [
{
"log_id": "some",
"log_limit": 0
}
]
}
@@ -0,0 +1,12 @@
{
"type": "text",
"text": "text",
"visibility": "visibile",
"visibility_actions": [
{
"log_id": "some",
"log_limit": 0,
"visibility_duration": 0
}
]
}
@@ -0,0 +1,28 @@
{
"log_id": "sample_card",
"states": [
{
"state_id": 0,
"div": {
"type": "text",
"text": "state zero",
"visibility_action": {
"log_id": "zero state visible",
"log_limit": 0
}
}
},
{
"state_id": 1,
"div": {
"type": "text",
"text": "state first",
"visibility_action": {
"log_id": "first state visible",
"log_limit": 0
}
}
}
]
}
@@ -0,0 +1,28 @@
{
"type": "state",
"id": "mystate",
"states": [
{
"state_id": "first",
"div": {
"type": "text",
"text": "state first",
"visibility_action": {
"log_id": "first state visible",
"log_limit": 0
}
}
},
{
"state_id": "second",
"div": {
"type": "text",
"text": "seconds first",
"visibility_action": {
"log_id": "second state visible",
"log_limit": 0
}
}
}
]
}