Last active
July 18, 2020 13:23
-
-
Save ryotapoi/654f6330766be3db8666ba71ad1a21a8 to your computer and use it in GitHub Desktop.
This file contains 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 UIKit | |
import Combine | |
// MARK: - UserDefaults | |
public protocol UserDefaultsProtocol: NSObject { | |
func value<Value: UserDefaultCompatible>(type: Value.Type, forKey key: String, default defaultValue: Value) -> Value | |
func setValue<Value: UserDefaultCompatible>(_ value: Value, forKey key: String) | |
} | |
extension UserDefaults: UserDefaultsProtocol { | |
public func value<Value: UserDefaultCompatible>(type: Value.Type = Value.self, forKey key: String, default defaultValue: Value) -> Value { | |
guard let object = object(forKey: key) else { return defaultValue } | |
return Value(userDefaultObject: object) ?? defaultValue | |
} | |
public func setValue<Value: UserDefaultCompatible>(_ value: Value, forKey key: String) { | |
set(value.toUserDefaultObject(), forKey: key) | |
} | |
} | |
// MARK: - UserDefaultCompatible | |
public protocol UserDefaultCompatible { | |
init?(userDefaultObject: Any) | |
func toUserDefaultObject() -> Any? | |
} | |
extension UserDefaultCompatible where Self: Codable { | |
public init?(userDefaultObject: Any) { | |
guard let data = userDefaultObject as? Data else { return nil } | |
do { | |
self = try JSONDecoder().decode(Self.self, from: data) | |
} catch { | |
return nil | |
} | |
} | |
public func toUserDefaultObject() -> Any? { | |
try? JSONEncoder().encode(self) | |
} | |
} | |
extension UserDefaultCompatible where Self: NSObject, Self: NSCoding { | |
public init?(userDefaultObject: Any) { | |
guard let data = userDefaultObject as? Data else { return nil } | |
if let value = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? Self { | |
self = value | |
} else { | |
return nil | |
} | |
} | |
public func toUserDefaultObject() -> Any? { | |
if let object = try? NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: false) { | |
return object | |
} else { | |
return nil | |
} | |
} | |
} | |
extension Array: UserDefaultCompatible where Element: UserDefaultCompatible { | |
private struct UserDefaultCompatibleError: Error {} | |
public init?(userDefaultObject: Any) { | |
guard let objects = userDefaultObject as? [Any] else { return nil } | |
do { | |
let values = try objects.map { (object: Any) -> Element in | |
if let element = Element(userDefaultObject: object) { | |
return element | |
} else { | |
throw UserDefaultCompatibleError() | |
} | |
} | |
self = values | |
} catch { | |
return nil | |
} | |
} | |
public func toUserDefaultObject() -> Any? { | |
map { $0.toUserDefaultObject() } | |
} | |
} | |
extension Dictionary: UserDefaultCompatible where Key == String, Value: UserDefaultCompatible { | |
private struct UserDefaultCompatibleError: Swift.Error {} | |
public init?(userDefaultObject: Any) { | |
guard let objects = userDefaultObject as? [String: Any] else { return nil } | |
do { | |
let values = try objects.mapValues { object -> Value in | |
if let value = Value(userDefaultObject: object) { | |
return value | |
} else { | |
throw UserDefaultCompatibleError() | |
} | |
} | |
self = values | |
} catch { | |
return nil | |
} | |
} | |
public func toUserDefaultObject() -> Any? { | |
mapValues { $0.toUserDefaultObject() } | |
} | |
} | |
extension Optional: UserDefaultCompatible where Wrapped: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
self = Wrapped(userDefaultObject: userDefaultObject) | |
} | |
public func toUserDefaultObject() -> Any? { | |
flatMap { $0.toUserDefaultObject() } | |
} | |
} | |
extension Int: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Self else { return nil } | |
self = userDefaultObject | |
} | |
public func toUserDefaultObject() -> Any? { | |
self | |
} | |
} | |
extension Double: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Self else { return nil } | |
self = userDefaultObject | |
} | |
public func toUserDefaultObject() -> Any? { | |
self | |
} | |
} | |
extension Float: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Self else { return nil } | |
self = userDefaultObject | |
} | |
public func toUserDefaultObject() -> Any? { | |
self | |
} | |
} | |
extension Bool: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Self else { return nil } | |
self = userDefaultObject | |
} | |
public func toUserDefaultObject() -> Any? { | |
self | |
} | |
} | |
extension String: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Self else { return nil } | |
self = userDefaultObject | |
} | |
public func toUserDefaultObject() -> Any? { | |
self | |
} | |
} | |
extension URL: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Data else { return nil } | |
guard let url = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(userDefaultObject) as? URL else { return nil } | |
self = url | |
} | |
public func toUserDefaultObject() -> Any? { | |
try? NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: false) | |
} | |
} | |
extension Date: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Self else { return nil } | |
self = userDefaultObject | |
} | |
public func toUserDefaultObject() -> Any? { | |
self | |
} | |
} | |
extension Data: UserDefaultCompatible { | |
public init?(userDefaultObject: Any) { | |
guard let userDefaultObject = userDefaultObject as? Self else { return nil } | |
self = userDefaultObject | |
} | |
public func toUserDefaultObject() -> Any? { | |
self | |
} | |
} | |
// Combine | |
extension UserDefaults { | |
public class Publisher<Output>: NSObject, Combine.Publisher where Output: UserDefaultCompatible, Output: Equatable { | |
public typealias Failure = Never // エラーは発生しないものとする | |
private let key: String // キー文字列 | |
private let defaultValue: Output // キー文字列に対応する値がない場合の値 | |
private let userDefaults: UserDefaultsProtocol // UserDefaultsの本体 | |
private let subject: CurrentValueSubject<Output, Never> // CurrentValueSubjectに処理を移譲して簡単に実装する | |
public var value: Output { | |
get { subject.value } | |
set { | |
if newValue != subject.value { | |
subject.value = newValue | |
// 値が設定された時はUserDefaultsに保存する | |
userDefaults.setValue(newValue, forKey: key) | |
} | |
} | |
} | |
// テストなどでUserDefaultsに値を保存したくない時に備え、念のためUserDefaultsProtocolを使う | |
// ただテストを考慮しても、あまり必要ない気がしている | |
public init(key: String, default defaultValue: Output, userDefaults: UserDefaultsProtocol = UserDefaults.standard) { | |
self.key = key | |
self.defaultValue = defaultValue | |
self.userDefaults = userDefaults | |
self.subject = .init(userDefaults.value(type: Output.self, forKey: key, default: defaultValue)) | |
super.init() | |
// KVOでUserDefaultsの監視開始 | |
userDefaults.addObserver(self, forKeyPath: key, options: .new, context: nil) | |
} | |
deinit { | |
// KVO監視終了 | |
userDefaults.removeObserver(self, forKeyPath: key) | |
} | |
// KVO監視で変更があったときに呼ばれる | |
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { | |
if keyPath == key { | |
let newValue = change?[.newKey] | |
.flatMap(Output.init(userDefaultObject:)) | |
?? defaultValue | |
if newValue != subject.value { | |
// UserDefaultsの更新通知から、UserDefaultsにさらに保存する必要はない | |
// `subject.value` に直接値を入れ、値の通知だけ行うようにする | |
subject.value = newValue | |
} | |
} | |
} | |
// Publisherの必須メソッド | |
// 何も考えずCurrentValueSubjectに処理を移譲する | |
public func receive<S>( | |
subscriber: S | |
) where S: Combine.Subscriber, Failure == S.Failure, Output == S.Input { | |
subject.receive(subscriber: subscriber) | |
} | |
} | |
} | |
// Property Wrapper | |
@propertyWrapper | |
public struct UserDefault<Value> where Value: UserDefaultCompatible, Value: Equatable { | |
private let publisher: UserDefaults.Publisher<Value> | |
// `@UserDefault("key", userDefaults: UserDefaults.standard) var a: Int = 0` の場合 | |
// 最初の引数 `wrappedValue` にプロパティの初期値 `0` が渡される | |
// それ以降の引数は `@UserDefault` の引数が渡される | |
public init(wrappedValue defaultValue: Value, _ key: String, userDefaults: UserDefaultsProtocol = UserDefaults.standard) { | |
publisher = .init(key: key, default: defaultValue, userDefaults: userDefaults) | |
} | |
// プロパティの値の読み書き時はこれが呼ばれる | |
public var wrappedValue: Value { | |
get { publisher.value } | |
set { publisher.value = newValue } | |
} | |
// プロパティに$を付けた時はこれが呼ばれる | |
public var projectedValue: UserDefaults.Publisher<Value> { publisher } | |
} | |
// Test Code | |
struct User: Codable, Equatable, UserDefaultCompatible { | |
var name: String | |
} | |
class Record: NSObject, NSCoding, UserDefaultCompatible { | |
var name: String? = "abc" | |
init(name: String?) { | |
self.name = name | |
} | |
required init?(coder: NSCoder) { | |
name = coder.decodeObject(forKey: "name") as? String | |
} | |
func encode(with coder: NSCoder) { | |
coder.encode(name, forKey: "name") | |
} | |
override func isEqual(_ object: Any?) -> Bool { | |
guard let object = object as? Record else { return false } | |
return name == object.name | |
} | |
override var hash: Int { | |
var hasher = Hasher() | |
hasher.combine(name) | |
return hasher.finalize() | |
} | |
} | |
struct Settings { | |
@UserDefault("user") | |
var user: User = .init(name: "abc") | |
@UserDefault("userOptional") | |
var userOptional: User? = nil | |
@UserDefault("users") | |
var users: [User] = [] | |
@UserDefault("userArrayOptional") | |
var userArrayOptional: [User]? = nil | |
@UserDefault("userDictionary") | |
var userDictionary: [String: User] = [:] | |
@UserDefault("userDictionaryOptional") | |
var userDictionaryOptional: [String: User]? = nil | |
@UserDefault("int") | |
var int: Int = 5 | |
@UserDefault("intOptional") | |
var intOptional: Int? = nil | |
@UserDefault("doubleValue") | |
var doubleValue: Double = 23.57 | |
@UserDefault("doubleValueOptional") | |
var doubleValueOptional: Double? = nil | |
@UserDefault("floatValue") | |
var floatValue: Float = 1.23 | |
@UserDefault("floatValueOptional") | |
var floatValueOptional: Float? = nil | |
@UserDefault("bool") | |
var bool: Bool = true | |
@UserDefault("boolOptional") | |
var boolOptional: Bool? = nil | |
@UserDefault("string") | |
var string: String = "defaultString" | |
@UserDefault("stringOptional") | |
var stringOptional: String? = nil | |
@UserDefault("url") | |
var url: URL = URL(string: "https://google.com")! | |
@UserDefault("urlOptional") | |
var urlOptional: URL? = nil | |
@UserDefault("date") | |
var date: Date = .init(timeIntervalSinceReferenceDate: 0) | |
@UserDefault("dateOptional") | |
var dateOptional: Date? = nil | |
@UserDefault("data") | |
var data: Data = .init() | |
@UserDefault("dataOptional") | |
var dataOptional: Data? = nil | |
@UserDefault("record") | |
var record: Record = .init(name: "default record name") | |
@UserDefault("recordOptional") | |
var recordOptional: Record? = nil | |
} | |
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) | |
var settings = Settings() | |
settings.$user.sink { print($0) } | |
settings.user | |
settings.user = User(name: "change name") | |
settings.user | |
settings.$userOptional.sink { print($0) } | |
settings.userOptional | |
settings.userOptional = User(name: "optional user") | |
settings.userOptional | |
settings.userOptional = nil | |
settings.userOptional | |
settings.users | |
settings.users = [User(name: "change name")] | |
settings.users | |
settings.userArrayOptional | |
settings.userArrayOptional = [User(name: "change name")] | |
settings.userArrayOptional | |
settings.userDictionary | |
settings.userDictionary = ["key": User(name: "change name")] | |
settings.userDictionary | |
settings.userDictionaryOptional | |
settings.userDictionaryOptional = ["key": User(name: "change name")] | |
settings.userDictionaryOptional | |
settings.int | |
settings.int = 432 | |
settings.int | |
settings.intOptional | |
settings.intOptional = 543 | |
settings.intOptional | |
settings.doubleValue | |
settings.doubleValue = 5.68 | |
settings.doubleValue | |
settings.doubleValueOptional | |
settings.doubleValueOptional = 111.3 | |
settings.doubleValueOptional | |
settings.floatValue | |
settings.floatValue = 99.99 | |
settings.floatValue | |
settings.floatValueOptional | |
settings.floatValueOptional = -32 | |
settings.floatValueOptional | |
settings.bool | |
settings.bool = false | |
settings.bool | |
settings.boolOptional | |
settings.boolOptional = true | |
settings.boolOptional | |
settings.string | |
settings.string = "string" | |
settings.string | |
settings.stringOptional | |
settings.stringOptional = "optional string" | |
settings.stringOptional | |
settings.url | |
settings.url = URL(string: "https://yahoo.co.jp")! | |
settings.url | |
settings.urlOptional | |
settings.urlOptional = URL(string: "https://yahoo.co.jp") | |
settings.urlOptional | |
settings.date | |
settings.date = Date(timeIntervalSinceReferenceDate: 3600) | |
settings.date | |
settings.dateOptional | |
settings.dateOptional = Date(timeIntervalSinceReferenceDate: 7200) | |
settings.dateOptional | |
settings.data | |
settings.data = Data(base64Encoded: "1234")! | |
settings.data | |
settings.dataOptional | |
settings.dataOptional = Data(base64Encoded: "1234")! | |
settings.dataOptional | |
settings.record | |
settings.record = Record(name: "change name") | |
settings.record | |
settings.recordOptional | |
settings.recordOptional = Record(name: "change name") | |
settings.recordOptional | |
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) | |
do { | |
let publisher1: UserDefaults.Publisher<Int> = .init(key: "key1", default: 1) | |
let publisher2: UserDefaults.Publisher<Int> = .init(key: "key2", default: 100) | |
publisher1.value // => 1 | |
publisher2.value // => 100 | |
let cancelable = publisher1.assign(to: \.value, on: publisher2) | |
// 現在値を持っているためassignした瞬間値が流れるので、publisher1,2は同じ値になる | |
publisher1.value // => 1 | |
publisher2.value // => 1 | |
publisher1.value = 2 | |
// 上流であるpublisher1を更新するとpublisher2に値が流れる | |
publisher1.value // => 2 | |
publisher2.value // => 2 | |
publisher2.value = 200 | |
// 下流であるpublisher2を更新してもpublisher1には影響しない | |
publisher1.value // => 2 | |
publisher2.value // => 200 | |
} | |
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) | |
do { | |
let publisher1: UserDefaults.Publisher<Int> = .init(key: "key1", default: 1) | |
let publisher2: UserDefaults.Publisher<Int> = .init(key: "key2", default: 100) | |
let cancelable = publisher1.assign(to: \.value, on: publisher2) | |
publisher1.value // => 1 | |
publisher2.value // => 1 | |
cancelable.cancel() | |
publisher1.value = 2 | |
// cancelすればpublisher1を更新してもpublisher2に値は流れない | |
publisher1.value // => 2 | |
publisher2.value // => 1 | |
} | |
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) | |
struct First { | |
@UserDefault("number") | |
var number: Int = 10 | |
} | |
struct Second { | |
@UserDefault("number") | |
var number: Int = 10 | |
} | |
var first = First() | |
var second = Second() | |
first.$number.sink { print(#line, $0) } | |
first.number = 20 | |
first.number // => 20 | |
second.number // => 20 | |
second.number = 30 | |
first.number // => 30 | |
second.number // => 30 | |
let publisher = Publishers.Sequence<[Int], Never>(sequence: [40, 50, 60]) | |
let cancelable = publisher.assign(to: \.value, on: first.$number) | |
first.number // => 60 | |
second.number // => 60 | |
let input: PassthroughSubject<Int, Never> = .init() | |
let cancellable: AnyCancellable = input.assign(to: \.value, on: first.$number) | |
let output: AnyPublisher<Int, Never> = publisher.eraseToAnyPublisher() | |
output.sink { print(#line, $0) } | |
input.send(70) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment