3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

UserDefaultsにSwiftUIのColorを保存したい

Posted at

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)
    }
}

UserDefaultSample.gif

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?