Skip to content

Commit

Permalink
add CachingItem
Browse files Browse the repository at this point in the history
  • Loading branch information
lkzhao committed Nov 9, 2024
1 parent bc1d1b2 commit 58f5a6f
Show file tree
Hide file tree
Showing 3 changed files with 242 additions and 0 deletions.
9 changes: 9 additions & 0 deletions Sources/UIComponent/Core/ComponentView/ComponentEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,15 @@ public final class ComponentEngine {
isRendering = false
}

// MARK: - Data Caching

private lazy var cachingData: [String: Any] = [:]
internal func loadCachingData<T>(id: String, generator: () -> T) -> T {
let data = (cachingData[id] as? T) ?? generator()
cachingData[id] = data
return data
}

/// Ensures that the zoom view is centered within the scroll view if it is smaller than the scroll view's bounds.
public func ensureZoomViewIsCentered() {
guard let contentView else { return }
Expand Down
45 changes: 45 additions & 0 deletions Sources/UIComponent/Core/Model/Component/CachingItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// CachingItem.swift
// UIComponent
//
// Created by Luke Zhao on 11/8/24.
//

/// A Component that caches data item.
///
/// It is useful when you want to cache item that is expensive to generate
/// or if you want to share the same item across multiple reload.
///
/// The item will be released when the hosting view is released
///
/// `itemGenerator` will be called to generate the item if it's not in the view's cache.
/// `componentBuilder` will be called to build the component with the item.
public struct CachingItem<T, C: Component>: Component {
/// The key to identify the item in the cache.
public let key: String

/// The closure that generates the item. Will only be called if the item is not in the cache.
public let itemGenerator: () -> T

/// The closure that builds the component with the item
public let componentBuilder: (T) -> C

@Environment(\.hostingView) private var hostingView

/// Initializes a new `CachingItem` with the provided key, item generator, and component builder.
/// - Parameters:
/// - key: The key to identify the item in the cache.
/// - itemGenerator: The closure that generates the item. Will only be called if the item is not in the cache.
/// - componentBuilder: The closure that builds the component
public init(key: String, itemGenerator: @escaping () -> T, componentBuilder: @escaping (T) -> C) {
self.key = key
self.itemGenerator = itemGenerator
self.componentBuilder = componentBuilder
}

/// Component protocol method
public func layout(_ constraint: Constraint) -> C.R {
let data: T = hostingView?.componentEngine.loadCachingData(id: key, generator: itemGenerator) ?? itemGenerator()
return componentBuilder(data).layout(constraint)
}
}
188 changes: 188 additions & 0 deletions Tests/UIComponentTests/DataCachingTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//
// File.swift
// UIComponent
//
// Created by Luke Zhao on 11/8/24.
//

import Testing
@testable import UIComponent
import UIKit

@Suite("Data Caching")
@MainActor
struct DataCachingTest {

class DataHolder<T> {
var data: T
init(data: T) {
self.data = data
}
}

@Test func testCachingData() throws {
var view: UIView! = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
var callCount = 0
weak var holder: DataHolder<String>?
view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
let h = DataHolder(data: "1")
holder = h
return h
}, componentBuilder: { (data: DataHolder<String>) in
Text(data.data)
})
view.componentEngine.reloadData()
#expect(callCount == 1)
#expect(view.subviews.count == 1)
let existingLabel = view.subviews.first as? UILabel
#expect(existingLabel?.text == "1")
#expect(holder != nil)

view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
let h = DataHolder(data: "1")
holder = h
return h
}, componentBuilder: { (data: DataHolder<String>) in
Text(data.data)
})
view.componentEngine.reloadData()
#expect(callCount == 1)
#expect(holder != nil)

view.componentEngine.component = Text("2")
view.componentEngine.reloadData()
#expect(callCount == 1)
#expect(holder != nil)

// data should be released
view = nil
#expect(callCount == 1)
#expect(holder == nil)
}

@Test func testCachingDataWithWrappedView() throws {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
var callCount = 0
view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
}).view()
view.componentEngine.reloadData()
#expect(callCount == 1)
#expect(view.subviews.count == 1)

view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
}).view()
view.componentEngine.reloadData()
#expect(callCount == 1)
}

@Test func testCachingDataScrolling() throws {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
var callCount = 0

view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
}).view()
view.componentEngine.reloadData()
#expect(callCount == 1)
#expect(view.subviews.count == 1)

view.componentEngine.component = VStack {
Space(height: 5000)
CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
}).view()
}
view.componentEngine.reloadData()
#expect(view.subviews.count == 0)
#expect(callCount == 1)

view.bounds.origin = CGPoint(x: 0, y: 5000)
view.componentEngine.reloadData()
#expect(view.subviews.count == 1)
#expect(callCount == 1)
}

@Test func testCachingDataWithFixedSizeScrolling() throws {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
var callCount = 0
view.componentEngine.component = VStack {
Space(height: 5000)
CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
}).size(width: 100, height: 100)
}
view.componentEngine.reloadData()
#expect(view.subviews.count == 0)
#expect(callCount == 0)

view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
}).size(width: 100, height: 100)
view.componentEngine.reloadData()
#expect(callCount == 1)
#expect(view.subviews.count == 1)

view.componentEngine.component = VStack {
Space(height: 5000)
CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
}).size(width: 100, height: 100)
}
view.componentEngine.reloadData()
#expect(view.subviews.count == 0)
#expect(callCount == 1)

view.bounds.origin = CGPoint(x: 0, y: 5000)
view.componentEngine.reloadData()
#expect(view.subviews.count == 1)
#expect(callCount == 1)
}

@Test func testCachingDataWithDifferentType() throws {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 500, height: 500))
var callCount = 0
view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
return 1
}, componentBuilder: {
Text("\($0)")
})
view.componentEngine.reloadData()
#expect(callCount == 1)
#expect(view.subviews.count == 1)

view.componentEngine.component = CachingItem(key: "1", itemGenerator: {
callCount += 1
return "1"
}, componentBuilder: {
Text($0)
})
view.componentEngine.reloadData()
#expect(callCount == 2)
}
}

0 comments on commit 58f5a6f

Please sign in to comment.