Skip to content

Instantly share code, notes, and snippets.

@danhalliday
Last active December 21, 2023 05:37
Show Gist options
  • Save danhalliday/a4e70a866334d4b254bd61f31420261a to your computer and use it in GitHub Desktop.
Save danhalliday/a4e70a866334d4b254bd61f31420261a to your computer and use it in GitHub Desktop.
Sketch of a settings dependency.
import SwiftUI
import Dependencies
import AsyncExtensions
public struct Setting<Value:Sendable>: Sendable {
let id: String
let name: String
let fallback: Value
let load: @Sendable (String) -> Value?
let save: @Sendable (String, Value) -> Void
let stream: @Sendable (String) -> AsyncStream<Value?>
}
// MARK: - API
extension Setting {
public var value: Value {
get { load(id) ?? fallback } nonmutating set { save(id, newValue) }
}
public var values: AsyncStream<Value> {
stream(id).map { value in value ?? fallback }.eraseToStream()
}
public var changes: AsyncStream<Void> {
stream(id).map { _ in }.eraseToStream()
}
public var binding: Binding<Value> {
Binding(get: { value }, set: { newValue in value = newValue })
}
}
// MARK: - Constructors
extension Setting {
static func mock(_ fallback: Value) -> Setting {
let subject = AsyncCurrentValueSubject(Optional<Value>.none)
return Setting(
id: "",
name: "",
fallback: fallback,
load: { _ in subject.value },
save: { _, newValue in subject.value = newValue },
stream: { _ in subject.eraseToStream() }
)
}
static func constant(_ fallback: Value) -> Setting {
Setting(
id: "",
name: "",
fallback: fallback,
load: { _ in nil },
save: { _, _ in },
stream: { _ in [].async.eraseToStream() }
)
}
// TODO: Maybe something like Setting.userDefaultsBased(...)
}
import os.log
import Dependencies
import DependenciesAdditions
extension Settings {
static var live: Settings {
@Dependency(\.userDefaults) var defaults
@Dependency(\.logger["Settings"]) var logger
return Settings(
editorFontSize:
Setting(
id: "editor-font-size",
name: "Editor Font Size",
fallback: 24,
load: { id in defaults.integer(forKey: id) },
save: { id, newValue in defaults.set(newValue, forKey: id) },
stream: { id in defaults.integerValues(forKey: id).eraseToStream() }
)
.logged(using: logger)
.tracked(),
isDarkModeEnabled:
Setting(
id: "dark-mode",
name: "Dark Mode",
fallback: false,
load: { id in defaults.bool(forKey: id) },
save: { id, newValue in defaults.set(newValue, forKey: id) },
stream: { id in defaults.boolValues(forKey: id).eraseToStream() }
)
.logged(using: logger)
.tracked()
)
}
}
// MARK: - Helpers
extension Setting {
func logged(using logger: Logger) -> Self {
Setting(
id: id,
name: name,
fallback: fallback,
load: load,
save: { id, newValue in
let name = self.name
let description = String(describing: newValue)
logger.info("Setting <\(name)> changed to <\(description)>")
save(id, value)
},
stream: stream
)
}
func tracked(/* eg: using analytics: Analytics */) -> Self {
Setting(
id: id,
name: name,
fallback: fallback,
load: load,
save: { id, newValue in
// eg: analytics.track(property: id, value: newValue)
save(id, value)
},
stream: stream
)
}
}
extension Settings {
static var mock: Settings {
Settings(
editorFontSize: .mock(24),
isDarkModeEnabled: .mock(false)
)
}
}
import Dependencies
import AsyncExtensions
public struct Settings: Sendable {
public var editorFontSize: Setting<Int>
public var isDarkModeEnabled: Setting<Bool>
}
// MARK: - Dependency
extension Settings: DependencyKey {
public static let liveValue = Settings.live
public static let previewValue = Settings.mock
public static let testValue = Settings.mock
}
extension DependencyValues {
public var settings: Settings {
get { self[Settings.self] }
set { self[Settings.self] = newValue }
}
}
// MARK: - Helpers
extension Settings {
public var changes: AsyncStream<Void> {
merge(
editorFontSize.changes,
isDarkModeEnabled.changes
).eraseToStream()
}
}
import Foundation
import Dependencies
@MainActor public final class SettingsScreen: ObservableObject {
public init() {}
// MARK: - Dependencies
@Dependency(\.settings.changes) var changes
@Dependency(\.settings.editorFontSize) var editorFontSize
@Dependency(\.settings.isDarkModeEnabled) var isDarkModeEnabled
// MARK: - Tasks
func trackSettingsChanges() async {
for await _ in changes {
objectWillChange.send()
}
}
func trackEditorFontSizeValues() async {
for await size in editorFontSize.values {
print("Editor font size changed to: \(size)")
}
}
// MARK: - State
var darkModeStatusLabel: String {
"Dark Mode is \(isDarkModeEnabled.value ? "enabled" : "disabled")"
}
// MARK: - Actions
func toggleDarkModeButtonWasPressed() {
isDarkModeEnabled.value.toggle()
}
}
import SwiftUI
public struct SettingsScreenView: View {
@ObservedObject var model: SettingsScreen
public init(model: SettingsScreen) {
self.model = model
}
// MARK: - Body
public var body: some View {
List {
Text(model.darkModeStatusLabel)
Toggle(isOn: model.isDarkModeEnabled.binding) {
Text("Use Dark Mode")
}
Button { model.toggleDarkModeButtonWasPressed() } label: {
Text("Toggle Dark Mode")
}
Stepper(value: model.editorFontSize.binding, in: 16...64, step: 1) {
Text("Editor Font Size: \(model.editorFontSize.value)")
}
}
.task { await model.trackSettingsChanges() }
.task { await model.trackEditorFontSizeValues() }
}
}
struct SettingsScreenPreviews: PreviewProvider {
@StateObject static var model = SettingsScreen()
static var previews: some View {
SettingsScreenView(model: model)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment