UserDefaultsに既存の構造体(ここではSwiftUIのColor)を保存したいという話
履歴
2022年11月4日 初版
開発環境
XCode Version 14.0 (14A309)
Swift version 5.7
方法
SwiftUI.ColorをCodableに準拠させる
元となるレイアウト
ピッカーで色を選択すると背景色が変わるという単純なもの。
import SwiftUI
struct ContentView: View {
private static let gradientColors: [(start: Color, end: Color)] = [
(.init(hex: "#ff9a9e"), .init(hex: "#fad0c4")), // 0
(.init(hex: "#a18cd1"), .init(hex: "#fbc2eb")), // 1
(.init(hex: "#f6d365"), .init(hex: "#fda085")), // 2
(.init(hex: "#fbc2eb"), .init(hex: "#a6c1ee")) // 3
]
@State var backgroundGradientColor: [Color] = [.init(hex: "#6a85b6"), .init(hex: "#bac8e0")]
@State var selection: Int = -1
let colorOptions = ["ピンク", // 0
"濃い紫", // 1
"オレンジ", // 2
"ピンク紫" // 3
]
var body: some View {
ZStack {
LinearGradient(colors: backgroundGradientColor, startPoint: .topLeading, endPoint: .bottomTrailing)
VStack {
Spacer()
Text("UserDefault\nSamples")
.font(.system(size: 40, weight: .black, design: .rounded))
.foregroundColor(.white)
.multilineTextAlignment(.center)
.frame(height: 100)
Spacer()
Picker(selection: $selection) {
Text("未選択").tag(-1)
.font(.system(size: 20, weight: .black, design: .rounded))
.foregroundColor(.white)
ForEach(0..<colorOptions.count, id: \.self) { number in
Text(colorOptions[number])
.font(.system(size: 20, weight: .black, design: .rounded))
.foregroundColor(.white)
}
} label: {
Text("色を選択してください")
}
.onChange(of: selection) { newSelection in
if newSelection < 0 { return }
backgroundGradientColor = [Self.gradientColors[newSelection].start, Self.gradientColors[newSelection].end]
}
.pickerStyle(WheelPickerStyle())
.frame(maxWidth: .infinity)
.frame(maxHeight: 200)
.frame(minHeight: 150)
.frame(minHeight: 150)
.frame(minWidth: 375)
Spacer(minLength: 50)
}
}
.ignoresSafeArea()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension Color {
/// 16進数表現の色文字列からColorを生成する。
/// 色文字列からColorが生成ができない場合は**黒のColor**を生成する。
/// - Parameters:
/// - hex: 16進数の色文字列
/// - opacity: 透明度
init(hex: String, opacity: CGFloat = 1.0) {
let hexFormatted = hex.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
// 文字数が6じゃない場合は不正文字列
guard hexFormatted.count == 6 else {
self.init(red: 0, green: 0, blue: 0)
return
}
var rgbValue: UInt64 = 0
// String値をInt64にする。できない場合は不正文字列
guard Scanner(string: hexFormatted).scanHexInt64(&rgbValue) else {
self.init(red: 0, green: 0, blue: 0)
return
}
self.init(red: Double((rgbValue & 0xFF0000) >> 16) / 255.0,
green: Double((rgbValue & 0x00FF00) >> 8) / 255.0,
blue: Double((rgbValue & 0x0000FF)) / 255.0,
opacity: opacity)
}
}
この背景色をUserDefaultで保存したい(タプルではなく、startとendの2色を保存したい)
※タプルはまた別の問題。今回は既存の構造体を保存したいという問題に対するアプローチ。
NSKeyedArchiverは使えない
UIColorだったら以下の書き方もできたのだろうか。
試していないのでわからないが、UserDefaultsに対応していないClassを保存するには以下のように書けばよかったはず。
残念ながらSwiftUI.Colorには使えない。
// 使えない。コピペだめ絶対。
.onChange(of: selection) { newSelection in
//~~~中略~~~~//
do {
let startColorArchivedData = try NSKeyedArchiver.archivedData(withRootObject: Self.gradientColors[newSelection].start, requiringSecureCoding: false)
let endColorArchivedData = try NSKeyedArchiver.archivedData(withRootObject: Self.gradientColors[newSelection].end, requiringSecureCoding: false)
UserDefaults.standard.set(startColorArchivedData, forKey: "startColorArchivedData")
UserDefaults.standard.set(endColorArchivedData, forKey: "endColorArchivedData")
} catch {
fatalError("データの保存に失敗しました : \(error)")
}
}
Codableに準拠させる
Codableに準拠した構造体をJSONEncoderやJSONDecoderを使ってUserDefaultsに保存するのが吉である。
とはいえ、ColorはCodableに準拠していない。
extensionでColorをCodableに準拠させる。
extension Color: Codable {
enum CodingKeys: String, CodingKey {
case red
case green
case blue
case opacity
}
public func encode(to encoder: Encoder) throws {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var opacity: CGFloat = 0
var container = encoder.container(keyedBy: CodingKeys.self)
UIColor(self).getRed(&red, green: &green, blue: &blue, alpha: &opacity)
try container.encode(red, forKey: .red)
try container.encode(green, forKey: .green)
try container.encode(blue, forKey: .blue)
try container.encode(opacity, forKey: .opacity)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let red = try container.decode(Double.self, forKey: .red)
let green = try container.decode(Double.self, forKey: .green)
let blue = try container.decode(Double.self, forKey: .blue)
let opacity = try container.decode(Double.self, forKey: .opacity)
self.init(red: red, green: green, blue: blue, opacity: opacity)
}
}
UserDefaultsで使う
通常通りのUserDefaultsの使い方で使うことができる。
.onChange(of: selection) { newSelection in
if newSelection < 0 { return }
backgroundGradientColor = [Self.gradientColors[newSelection].start, Self.gradientColors[newSelection].end]
// 保存する
let jsonEncoder = JSONEncoder()
guard let startColorData = try? jsonEncoder.encode(Self.gradientColors[newSelection].start) else {
return
}
guard let endColorData = try? jsonEncoder.encode(Self.gradientColors[newSelection].end) else {
return
}
UserDefaults.standard.set(startColorData, forKey: "startColorData")
UserDefaults.standard.set(endColorData, forKey: "endColorData")
}
呼び出す時は.onAppearを使う。
.onAppear {
let jsonDecoder = JSONDecoder()
guard let startColorData = UserDefaults.standard.data(forKey: "startColorData"),
let endColorData = UserDefaults.standard.data(forKey: "endColorData"),
let startColor = try? jsonDecoder.decode(Color.self, from: startColorData),
let endColor = try? jsonDecoder.decode(Color.self, from: endColorData) else { return }
backgroundGradientColor = [startColor, endColor]
}
全文
//
// ContentView.swift
// UserDefaultsSample
//
// Created by masker on 2022/11/04.
//
import SwiftUI
struct ContentView: View {
private static let gradientColors: [(start: Color, end: Color)] = [
(.init(hex: "#ff9a9e"), .init(hex: "#fad0c4")), // 0
(.init(hex: "#a18cd1"), .init(hex: "#fbc2eb")), // 1
(.init(hex: "#f6d365"), .init(hex: "#fda085")), // 2
(.init(hex: "#fbc2eb"), .init(hex: "#a6c1ee")) // 3
]
@State var backgroundGradientColor: [Color] = [.init(hex: "#6a85b6"), .init(hex: "#bac8e0")]
@State var selection: Int = -1
let colorOptions = ["ピンク", // 0
"濃い紫", // 1
"オレンジ", // 2
"ピンク紫" // 3
]
var body: some View {
ZStack {
LinearGradient(colors: backgroundGradientColor, startPoint: .topLeading, endPoint: .bottomTrailing)
VStack {
Spacer()
Text("UserDefault\nSamples")
.font(.system(size: 40, weight: .black, design: .rounded))
.foregroundColor(.white)
.multilineTextAlignment(.center)
.frame(height: 100)
Spacer()
Picker(selection: $selection) {
Text("未選択").tag(-1)
.font(.system(size: 20, weight: .black, design: .rounded))
.foregroundColor(.white)
ForEach(0..<colorOptions.count, id: \.self) { number in
Text(colorOptions[number])
.font(.system(size: 20, weight: .black, design: .rounded))
.foregroundColor(.white)
}
} label: {
Text("色を選択してください")
}
.onChange(of: selection) { newSelection in
if newSelection < 0 { return }
backgroundGradientColor = [Self.gradientColors[newSelection].start, Self.gradientColors[newSelection].end]
let jsonEncoder = JSONEncoder()
guard let startColorData = try? jsonEncoder.encode(Self.gradientColors[newSelection].start) else {
return
}
guard let endColorData = try? jsonEncoder.encode(Self.gradientColors[newSelection].end) else {
return
}
UserDefaults.standard.set(startColorData, forKey: "startColorData")
UserDefaults.standard.set(endColorData, forKey: "endColorData")
}
.pickerStyle(WheelPickerStyle())
.frame(maxWidth: .infinity)
.frame(maxHeight: 200)
.frame(minHeight: 150)
.frame(minHeight: 150)
.frame(minWidth: 375)
Spacer(minLength: 50)
}
}
.ignoresSafeArea()
.onAppear {
let jsonDecoder = JSONDecoder()
guard let startColorData = UserDefaults.standard.data(forKey: "startColorData"),
let endColorData = UserDefaults.standard.data(forKey: "endColorData"),
let startColor = try? jsonDecoder.decode(Color.self, from: startColorData),
let endColor = try? jsonDecoder.decode(Color.self, from: endColorData) else { return }
backgroundGradientColor = [startColor, endColor]
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension Color {
/// 16進数表現の色文字列からColorを生成する。
/// 色文字列からColorが生成ができない場合は**黒のColor**を生成する。
/// - Parameters:
/// - hex: 16進数の色文字列
/// - opacity: 透明度
init(hex: String, opacity: CGFloat = 1.0) {
let hexFormatted = hex.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
// 文字数が6じゃない場合は不正文字列
guard hexFormatted.count == 6 else {
self.init(red: 0, green: 0, blue: 0)
return
}
var rgbValue: UInt64 = 0
// String値をInt64にする。できない場合は不正文字列
guard Scanner(string: hexFormatted).scanHexInt64(&rgbValue) else {
self.init(red: 0, green: 0, blue: 0)
return
}
self.init(red: Double((rgbValue & 0xFF0000) >> 16) / 255.0,
green: Double((rgbValue & 0x00FF00) >> 8) / 255.0,
blue: Double((rgbValue & 0x0000FF)) / 255.0,
opacity: opacity)
}
}
extension Color: Codable {
enum CodingKeys: String, CodingKey {
case red
case green
case blue
case opacity
}
public func encode(to encoder: Encoder) throws {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var opacity: CGFloat = 0
var container = encoder.container(keyedBy: CodingKeys.self)
UIColor(self).getRed(&red, green: &green, blue: &blue, alpha: &opacity)
try container.encode(red, forKey: .red)
try container.encode(green, forKey: .green)
try container.encode(blue, forKey: .blue)
try container.encode(opacity, forKey: .opacity)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let red = try container.decode(Double.self, forKey: .red)
let green = try container.decode(Double.self, forKey: .green)
let blue = try container.decode(Double.self, forKey: .blue)
let opacity = try container.decode(Double.self, forKey: .opacity)
self.init(red: red, green: green, blue: blue, opacity: opacity)
}
}