Skip to content

Instantly share code, notes, and snippets.

@kabiroberai
Created December 21, 2023 14:18
Show Gist options
  • Save kabiroberai/c696561073aaa277a4aec0746deb3d6e to your computer and use it in GitHub Desktop.
Save kabiroberai/c696561073aaa277a4aec0746deb3d6e to your computer and use it in GitHub Desktop.
Memoize your SwiftUI ViewModels
import SwiftUI
/// A view that initializes its associated `model` only once.
///
/// It's occasionally necessary to initialize a ViewModel-type object inside
/// a view body to pass along to a child view. `Memoize` allows you to
/// do this inline.
///
/// In the following example, `ChildViewModel.init` is only called once,
/// despite `MyView` being re-rendered every time the stepper value changes.
///
/// ```swift
/// struct MyView: View {
/// @State var number = 0
///
/// var body: some View {
/// Stepper("Number", value: $number)
///
/// Memoize(ChildViewModel()) {
/// ChildView($0)
/// }
/// }
/// }
/// ```
///
/// If you _do_ want to re-create `ChildViewModel` at some point, use the
/// `input` parameter. `ChildViewModel` will be re-created if `input` changes.
/// In the next example, `ChildViewModel` is recreated when `isBig` changes
/// (that is, when `number` goes from 4 to 5, or from 5 to 4.)
///
/// ```swift
/// struct MyView: View {
/// @State var number = 0
///
/// var body: some View {
/// Stepper("Number", value: $number)
///
/// Memoize(input: number > 5) {
/// ChildViewModel(isBig: $0)
/// } content: {
/// ChildView($0)
/// }
/// }
/// }
/// ```
///
/// - Important: When `input` changes, the previous `model` is discarded, not
/// cached. For example, if the `input` changes from `1 -> 2 -> 1`, `model(1)`
/// will be invoked twice in total.
public struct Memoize<Input: Equatable, Model, Content: View>: View {
private final class Storage: ObservableObject {
private let makeModel: (Input) -> Model
private var lastResult: (value: Model, input: Input)?
func model(for input: Input) -> Model {
if let lastResult, lastResult.input == input {
return lastResult.value
}
let value = makeModel(input)
lastResult = (value, input)
return value
}
init(makeModel: @escaping (Input) -> Model) {
self.makeModel = makeModel
}
}
@StateObject private var storage: Storage
private let content: (Model) -> Content
private let input: Input
/// Create a new memoized view which is recreated when `input` changes.
///
/// - Parameter input: A value which upon change will cause `model` to
/// be re-invoked.
///
/// - Parameter model: A closure used to create the model based on `input`.
///
/// - Parameter content: A closure that creates the child view based on the
/// memoized `model`.
///
/// - Important: When `input` changes, the previous `model` is discarded,
/// not cached. For example, if the `input` changes from `1 -> 2 -> 1`,
/// `model(1)` will be invoked twice in total.
public init(
input: Input,
_ model: @escaping (Input) -> Model,
@ViewBuilder content: @escaping (Model) -> Content
) {
self.content = content
self.input = input
// the important bit: StateObject.init takes an autoclosure
// and evaluates it lazily, once. this is what enables memoization.
self._storage = StateObject(wrappedValue: Storage(makeModel: model))
}
/// Create a new memoized view that only creates `model` once.
///
/// - Parameter model: A closure used to create the model.
///
/// - Parameter content: A closure that creates the child view based on the
/// memoized `model`.
public init(
_ model: @escaping () -> Model,
@ViewBuilder content: @escaping (Model) -> Content
) where Input == Never? {
self.init(input: nil) { _ in
model()
} content: { value in
content(value)
}
}
/// Create a new memoized view that only creates `model` once.
///
/// - Parameter model: An (auto)closure used to create the model.
///
/// - Parameter content: A closure that creates the child view based on the
/// memoized `model`.
public init(
_ model: @autoclosure @escaping () -> Model,
@ViewBuilder content: @escaping (Model) -> Content
) where Input == Never? {
self.init(model, content: content)
}
public var body: some View {
content(storage.model(for: input))
}
}
@kabiroberai
Copy link
Author

(licensed under WTFPL yada yada)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment