[Features] Supports @Feature-specific settings via @Option property wrapper

This commit is contained in:
Riley Testut 2023-04-14 16:17:31 -05:00
parent 415450a943
commit 4d30ef2929
6 changed files with 224 additions and 7 deletions

View File

@ -177,6 +177,8 @@
D539104029E88BC40006B350 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D539103F29E88BC40006B350 /* ContentView.swift */; };
D54F710229E89DCB009C069A /* SettingsUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54F710129E89DCB009C069A /* SettingsUserInfoKey.swift */; };
D54F710429E89DFC009C069A /* NotificationName+Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54F710329E89DFC009C069A /* NotificationName+Settings.swift */; };
D55C468F29E761C000EA6DE9 /* AnyFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55C468E29E761C000EA6DE9 /* AnyFeature.swift */; };
D55C469129E7631000EA6DE9 /* AnyOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55C469029E7631000EA6DE9 /* AnyOption.swift */; };
D5864970297734280081477E /* CheatMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586496F297734280081477E /* CheatMetadata.swift */; };
D586497229774ABD0081477E /* CheatBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D586497129774ABD0081477E /* CheatBase.swift */; };
D5864978297756CE0081477E /* CheatBaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5864977297756CE0081477E /* CheatBaseView.swift */; };
@ -189,6 +191,7 @@
D5D7C1FD29E60EEF00663793 /* OptionalProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D532B8BC29E5EDAD009EE27C /* OptionalProtocol.swift */; };
D5D7C20129E60F2000663793 /* LocalizedOptionValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C1E829E5FCDE00663793 /* LocalizedOptionValue.swift */; };
D5D7C20229E60F2000663793 /* OptionValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C1E629E5F90200663793 /* OptionValue.swift */; };
D5D7C20329E60F2000663793 /* Option.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58F39C529E0A473008B4100 /* Option.swift */; };
D5D7C20429E60F2000663793 /* Feature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A9BFFD29DDECF100A8D610 /* Feature.swift */; };
D5D7C20829E616CF00663793 /* FeatureContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C20729E616CF00663793 /* FeatureContainer.swift */; };
D5F82FB82981D3AC00B229AF /* LegacySearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */; };
@ -415,9 +418,12 @@
D539103F29E88BC40006B350 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
D54F710129E89DCB009C069A /* SettingsUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUserInfoKey.swift; sourceTree = "<group>"; };
D54F710329E89DFC009C069A /* NotificationName+Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+Settings.swift"; sourceTree = "<group>"; };
D55C468E29E761C000EA6DE9 /* AnyFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyFeature.swift; sourceTree = "<group>"; };
D55C469029E7631000EA6DE9 /* AnyOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyOption.swift; sourceTree = "<group>"; };
D586496F297734280081477E /* CheatMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatMetadata.swift; sourceTree = "<group>"; };
D586497129774ABD0081477E /* CheatBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBase.swift; sourceTree = "<group>"; };
D5864977297756CE0081477E /* CheatBaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatBaseView.swift; sourceTree = "<group>"; };
D58F39C529E0A473008B4100 /* Option.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Option.swift; sourceTree = "<group>"; };
D58F39C829E0A702008B4100 /* UserDefaults+OptionValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+OptionValues.swift"; sourceTree = "<group>"; };
D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
D5A9BFFD29DDECF100A8D610 /* Feature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feature.swift; sourceTree = "<group>"; };
@ -1024,6 +1030,7 @@
isa = PBXGroup;
children = (
D5A9BFFD29DDECF100A8D610 /* Feature.swift */,
D58F39C529E0A473008B4100 /* Option.swift */,
D517F6BB29E737F5000D14D0 /* Types */,
D5D7C1FE29E60EF700663793 /* Protocols */,
D5D7C1F829E60E8600663793 /* Extensions */,
@ -1043,6 +1050,8 @@
D5D7C1FE29E60EF700663793 /* Protocols */ = {
isa = PBXGroup;
children = (
D55C468E29E761C000EA6DE9 /* AnyFeature.swift */,
D55C469029E7631000EA6DE9 /* AnyOption.swift */,
D5D7C20729E616CF00663793 /* FeatureContainer.swift */,
D532B8BC29E5EDAD009EE27C /* OptionalProtocol.swift */,
);
@ -1530,10 +1539,13 @@
D5D7C20829E616CF00663793 /* FeatureContainer.swift in Sources */,
D517F6BA29E730DA000D14D0 /* SettingsName.swift in Sources */,
D5D7C20429E60F2000663793 /* Feature.swift in Sources */,
D55C469129E7631000EA6DE9 /* AnyOption.swift in Sources */,
D5D7C1FD29E60EEF00663793 /* OptionalProtocol.swift in Sources */,
D55C468F29E761C000EA6DE9 /* AnyFeature.swift in Sources */,
D5D7C20129E60F2000663793 /* LocalizedOptionValue.swift in Sources */,
D54F710429E89DFC009C069A /* NotificationName+Settings.swift in Sources */,
D54F710229E89DCB009C069A /* SettingsUserInfoKey.swift in Sources */,
D5D7C20329E60F2000663793 /* Option.swift in Sources */,
D5D7C20229E60F2000663793 /* OptionValue.swift in Sources */,
D5D7C1F929E60EA500663793 /* UserDefaults+OptionValues.swift in Sources */,
);

View File

@ -9,8 +9,13 @@
import SwiftUI
import Combine
public struct EmptyOptions
{
public init() {}
}
@propertyWrapper @dynamicMemberLookup
public final class Feature
public final class Feature<Options>: _AnyFeature
{
public let name: LocalizedStringKey
public let description: LocalizedStringKey?
@ -36,13 +41,55 @@ public final class Feature
}
}
public var wrappedValue: Feature {
public var wrappedValue: some Feature {
return self
}
public init(name: LocalizedStringKey, description: LocalizedStringKey? = nil)
private var options: Options
public init(name: LocalizedStringKey, description: LocalizedStringKey? = nil, options: Options = EmptyOptions())
{
self.name = name
self.description = description
self.options = options
self.prepareOptions()
}
// Use `KeyPath` instead of `WritableKeyPath` as parameter to allow accessing projected property wrappers.
public subscript<T>(dynamicMember keyPath: KeyPath<Options, T>) -> T {
get {
options[keyPath: keyPath]
}
set {
guard let writableKeyPath = keyPath as? WritableKeyPath<Options, T> else { return }
options[keyPath: writableKeyPath] = newValue
}
}
}
public extension Feature
{
var allOptions: [any AnyOption] {
let features = Mirror(reflecting: self.options).children.compactMap { (child) -> (any AnyOption)? in
let feature = child.value as? (any AnyOption)
return feature
}
return features
}
}
private extension Feature
{
func prepareOptions()
{
// Update option keys + feature
for case (let key?, let option as any _AnyOption) in Mirror(reflecting: self.options).children
{
// Remove leading underscore.
let sanitizedKey = key.dropFirst()
option.key = String(sanitizedKey)
option.feature = self
}
}
}

View File

@ -0,0 +1,79 @@
//
// Option.swift
// Delta
//
// Created by Riley Testut on 4/7/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import SwiftUI
import Combine
@propertyWrapper
public class Option<Value: OptionValue>: _AnyOption
{
// Assigned to property name.
public internal(set) var key: String = ""
// Used for `SettingsUserInfoKey.name` value in .settingsDidChange notification.
public var settingsKey: SettingsName {
guard let feature = self.feature else { return SettingsName(rawValue: self.key) }
let defaultsKey = feature.key + "_" + self.key
return SettingsName(rawValue: defaultsKey)
}
internal weak var feature: (any AnyFeature)?
private let defaultValue: Value
/// @propertyWrapper
public var projectedValue: some Option {
return self
}
public var wrappedValue: Value {
get {
do {
let wrappedValue = try UserDefaults.standard.optionValue(forKey: self.settingsKey.rawValue, type: Value.self)
return wrappedValue ?? self.defaultValue
}
catch {
print("[ALTLog] Failed to read option value for key \(self.settingsKey.rawValue).", error)
return self.defaultValue
}
}
set {
Task { @MainActor in
// Delay to avoid "Publishing changes from within view updates is not allowed" runtime warning.
(self.feature?.objectWillChange as? ObservableObjectPublisher)?.send()
}
do {
try UserDefaults.standard.setOptionValue(newValue, forKey: self.settingsKey.rawValue)
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [SettingsUserInfoKey.name: self.settingsKey, SettingsUserInfoKey.value: newValue])
}
catch {
print("[ALTLog] Failed to set option value for key \(self.settingsKey.rawValue).", error)
}
}
}
// Non-Optional
public init(wrappedValue: Value)
{
self.defaultValue = wrappedValue
}
// Optional, default = nil
public init() where Value: OptionalProtocol
{
self.defaultValue = Value.none
}
// Optional, default = non-nil
public init(wrappedValue: Value) where Value: OptionalProtocol
{
self.defaultValue = wrappedValue
}
}

View File

@ -0,0 +1,38 @@
//
// AnyFeature.swift
// DeltaFeatures
//
// Created by Riley Testut on 4/12/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import SwiftUI
@dynamicMemberLookup
public protocol AnyFeature<Options>: ObservableObject, Identifiable
{
associatedtype Options = EmptyOptions
var name: LocalizedStringKey { get }
var description: LocalizedStringKey? { get }
var key: String { get }
var settingsKey: SettingsName { get }
var isEnabled: Bool { get set }
var allOptions: [any AnyOption] { get }
subscript<T>(dynamicMember keyPath: KeyPath<Options, T>) -> T { get set }
}
extension AnyFeature
{
public var id: String { self.key }
}
// Don't expose `key` setter via AnyFeature protocol.
internal protocol _AnyFeature: AnyFeature
{
var key: String { get set }
}

View File

@ -0,0 +1,41 @@
//
// AnyOption.swift
// DeltaFeatures
//
// Created by Riley Testut on 4/12/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import SwiftUI
public protocol AnyOption<Value>: AnyObject, Identifiable
{
associatedtype Value: OptionValue
var key: String { get }
var settingsKey: SettingsName { get }
var value: Value { get set }
}
extension AnyOption
{
public var id: String { self.key }
}
// Don't expose `feature` or `key` setters via AnyOption protocol.
internal protocol _AnyOption: AnyOption
{
var key: String { get set }
var feature: (any AnyFeature)? { get set }
var wrappedValue: Value { get set }
}
extension _AnyOption
{
public var value: Value {
get { self.wrappedValue }
set { self.wrappedValue = newValue }
}
}

View File

@ -15,9 +15,9 @@ public protocol FeatureContainer
public extension FeatureContainer
{
var allFeatures: [Feature] {
let features = Mirror(reflecting: self).children.compactMap { (child) -> Feature? in
let feature = child.value as? Feature
var allFeatures: [any AnyFeature] {
let features = Mirror(reflecting: self).children.compactMap { (child) -> (any AnyFeature)? in
let feature = child.value as? any AnyFeature
return feature
}
return features
@ -26,7 +26,7 @@ public extension FeatureContainer
func prepareFeatures()
{
// Assign keys to property names.
for case (let key?, let feature as Feature) in Mirror(reflecting: self).children
for case (let key?, let feature as any _AnyFeature) in Mirror(reflecting: self).children
{
// Remove leading underscore.
let sanitizedKey = key.dropFirst()