Created
December 21, 2023 14:18
-
-
Save kabiroberai/c696561073aaa277a4aec0746deb3d6e to your computer and use it in GitHub Desktop.
Memoize your SwiftUI ViewModels
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
(licensed under WTFPL yada yada)