第9回 グラデーションの動的変更 | SwiftUIでカラーピッカーを作る

SwiftUIでカラーピッカーを作る連載記事です。前回までの実装ではモデルがチャネル毎、更にグラデーションの情報とチャネルの現在値の情報が分かれていて、動的にグラデーションを変化させる処理の実装を困難にしています。今回はこれらを統合して、カラーピッカー全体の情報を持つモデルクラスに作り変え、同時にグラデーションを動的に変更する処理を実装します。

目次

グラデーションの動的変更

前回までの実装でグラデーションは各チャネルの値を0.0から1.0まで変化させた状態を表示しています。このときに作成する色は対象のチャネル以外のチャネルの値を0.0に固定しています。

これを0.0に固定せず、各チャネルの現在値を使用する様に変更するとグラデーションを色を動的に変化させられます。

スライダーのノブを移動して実際に生成される色がグラデーションと一致するようになるので、分かりやすいスライダーになります。

ColorPickerModelの追加

ColorPickerChannelModelクラスとColorPickerGradationModelクラスを元にして、ColorPickerModelクラスを作ります。

現在値のプロパティを定義する

import Foundation
import SwiftUI

class ColorPickerModel : ObservableObject {
    /// 現在値: Red
    @Published var red: Double = 0.0
    
    /// 現在値: Green
    @Published var green: Double = 0.0
    
    /// 現在値: Blue
    @Published var blue: Double = 0.0    
}

現在値を元に色を取得する

各チャネルの現在値を元に色を取得するプロパティを追加します。

    /// 選択色
    var color: Color {
        Color(red: red, green: green, blue: blue)
    }

現在値の丸め込み処理付きの変更メソッド

現在値を設定するときに、丸め込み処理を行うメソッドを実装します。チャネル毎にメソッドを用意するしても良いのですが、他にもチャネル毎に処理する必要があるメソッドが出てくると思いますので、チャネルの指定を引数に取る形式で実装します。

    /// チャネル
    enum Channel {
        case red
        case green
        case blue
    }
    
    /// 現在値を変更する
    /// - Parameters:
    ///   - value: 新しい値。範囲外のときは丸め込みされる
    ///   - channel: 変更するチャネル
    func changeCurrentValue(_ value: Double, channel: Channel) {
        var newValue = value
        if newValue < 0.0 {
            newValue = 0.0
        } else if newValue > 1.0 {
            newValue = 1.0
        }
        
        switch channel {
        case .red:
            red = newValue
        case .green:
            green = newValue
        case .blue:
            blue = newValue
        }
    }

編集中のテキストとバインディング

編集中のテキストを代入するプロパティをチャネル毎に実装します。また、バインディングは動的に作成するので、チャネルを引数にとるメソッドを実装します。

    /// 入力中のテキスト: Red
    private var fieldTextStoreRed: String?
    
    /// 入力中のテキスト: Green
    private var fieldTextStoreGreen: String?
    
    /// 入力中のテキスト: Blue
    private var fieldTextStoreBlue: String?
    
    /// 入力中のテキストのバインディング: Red
    var fieldTextRed: Binding<String> {
        Binding {
            self.fieldTextStoreRed ?? String(format: "%g", self.red)
        } set: {
            self.fieldTextStoreRed = $0
            if let value = Double($0) {
                self.changeCurrentValue(value, channel: .red)
            }
        }
    }
    
    /// 入力中のテキストのバインディング: Green
    var fieldTextGreen: Binding<String> {
        Binding {
            self.fieldTextStoreGreen ?? String(format: "%g", self.green)
        } set: {
            self.fieldTextStoreGreen = $0
            if let value = Double($0) {
                self.changeCurrentValue(value, channel: .green)
            }
        }
    }
    
    /// 入力中のテキストのバインディング: Blue
    var fieldTextBlue: Binding<String> {
        Binding {
            self.fieldTextStoreBlue ?? String(format: "%g", self.blue)
        } set: {
            self.fieldTextStoreBlue = $0
            if let value = Double($0) {
                self.changeCurrentValue(value, channel: .blue)
            }
        }
    }
    
    /// 入力中のテキストをクリアする
    /// - Parameters:
    ///   - channel: チャネル
    func clearFieldText(channel: Channel) {
        switch channel {
        case .red:
            fieldTextStoreRed = nil
        case .green:
            fieldTextStoreGreen = nil
        case .blue:
            fieldTextStoreBlue = nil
        }
    }

グラデーションビューのサイズ

グラデーションビューのサイズを入れるプロパティもチャネル毎に定義します。実際には同じサイズなので一つでも良いのですが。。。

    /// グラデーションビューのサイズ: Red
    @Published var gradationViewSizeRed: CGSize = CGSize()
    
    /// グラデーションビューのサイズ: Green
    @Published var gradationViewSizeGreen: CGSize = CGSize()
    
    /// グラデーションビューのサイズ: Blue
    @Published var gradationViewSizeBlue: CGSize = CGSize()

グラデーションの開始色と終了色

グラデーションの開始色と終了色を取得するプロパティを定義します。こちらもチャネル毎です。

    /// グラデーションの開始色: Red
    var startColorRed: Color {
        Color(red: 0.0, green: self.green, blue: self.blue)
    }
    
    /// グラデーションの終了色: Red
    var endColorRed: Color {
        Color(red: 1.0, green: self.green, blue: self.blue)
    }
    
    /// グラデーションの開始色: Green
    var startColorGreen: Color {
        Color(red: self.red, green: 0.0, blue: self.blue)
    }
    
    /// グラデーションの終了色: Green
    var endColorGreen: Color {
        Color(red: self.red, green: 1.0, blue: self.blue)
    }
    
    /// グラデーションの開始色: Blue
    var startColorBlue: Color {
        Color(red: self.red, green: self.green, blue: 0.0)
    }
    
    /// グラデーションの終了色: Blue
    var endColorBlue: Color {
        Color(red: self.red, green: self.green, blue: 1.0)
    }

PickerGradationViewの対応

ColorPickerChannelModelColorPickerGradationModelを使っているところをColorPickerModelに置き換えます。

モデルクラスの変更

PickerGradationViewから対応します。ColorPickerChannelModelクラスとColorPickerGradationModelクラスのインスタンスを代入するプロパティを削除します。代わりに、ColorPicker.ChannelColorPickerModelクラスのインスタンスを代入するプロパティを追加します。

struct PickerGradationView: View {

    var channel: ColorPickerModel.Channel
    @ObservedObject var model: ColorPickerModel

indicatorXプロパティの実装変更

indicatorXプロパティはchannelプロパティで指定されたチャネルの値を使って計算された値になるように変更します。

    /// ノブのX座標
    var indicatorX: CGFloat {
        switch channel {
        case .red:
            return indicatorXRed
        case .green:
            return indicatorXGreen
        case .blue:
            return indicatorXBlue
        }
    }
    
    var indicatorXRed: CGFloat {
        model.gradationViewSizeRed.width * model.red - indicatorSize / 2
    }
    
    var indicatorXGreen: CGFloat {
        model.gradationViewSizeGreen.width * model.green - indicatorSize / 2
    }
    
    var indicatorXBlue: CGFloat {
        model.gradationViewSizeBlue.width * model.blue - indicatorSize / 2
    }

dragプロパティの実装変更

dragプロパティの実装を変更します。計算時のグラデーションビューの幅はchannelプロパティで指定されたチャネル用のグラデーションビューの幅を使うようにします。また、changeCurrentValueメソッドとclearFieldTextメソッドはチャネルを指定するようになったので、チャネルの指定を追加します。

    var drag: some Gesture {
        DragGesture()
            .onChanged { dragValue in
                let width: Double
                switch channel {
                case .red:
                    width = self.model.gradationViewSizeRed.width
                case .green:
                    width = self.model.gradationViewSizeGreen.width
                case .blue:
                    width = self.model.gradationViewSizeBlue.width
                }
                
                let value = dragValue.location.x / width
                
                model.changeCurrentValue(value, channel: channel)
                model.clearFieldText(channel: channel)
            }
    }

グラデーションの開始色と終了色の取得

グラデーションの開始色と終了色を取得するプロパティを追加します。channelプロパティの値によって参照するプロパティを変更します。

    var startColor: Color {
        switch channel {
        case .red:
            return model.startColorRed
        case .green:
            return model.startColorGreen
        case .blue:
            return model.startColorBlue
        }
    }
    
    var endColor: Color {
        switch channel {
        case .red:
            return model.endColorRed
        case .green:
            return model.endColorGreen
        case .blue:
            return model.endColorBlue
        }
    }

追加したstartColorプロパティとendColorプロパティを使ってグラデーションを描くようにGradientの引数を変更します。

    var body: some View {
        ZStack(alignment: .leading) {
            GeometryReader() { geometry in
                LinearGradient(gradient: Gradient(colors: [startColor, endColor]),
                               startPoint: UnitPoint(x: 0, y: 0),
                               endPoint: UnitPoint(x: 1, y: 0))
            }

タップされたときの処理の変更

タップされたときに現在値の更新と入力中の文字列のクリアを行います。このときに変更する対象もchannelプロパティによって変更するようにコードを変更します。

    var body: some View {
        ZStack(alignment: .leading) {
            GeometryReader() { geometry in
                LinearGradient(gradient: Gradient(colors: [startColor, endColor]),
                               startPoint: UnitPoint(x: 0, y: 0),
                               endPoint: UnitPoint(x: 1, y: 0))
            }
            .frame(height: gradationHeigh)
            .background {
                GeometryReader { geometry in
                    Path { path in
                        Task.detached {
                            await updateViewSize(geometry.size)
                        }
                    }
                }
            }
            .onTapGesture { point in
                switch self.channel {
                case .red:
                    model.red = point.x / model.gradationViewSizeRed.width
                case .green:
                    model.green = point.x / model.gradationViewSizeGreen.width
                case .blue:
                    model.blue = point.x / model.gradationViewSizeBlue.width
                }
                model.clearFieldText(channel: channel)
            }
            .gesture(drag)

            PickerIndicator()
                .frame(width: indicatorSize, height: indicatorSize)
                .offset(x: indicatorX)
        }
    }

updateViewSizeの変更

updateViewSizeメソッドで更新する対象をchannelプロパティによって変更するようにコードを変更します。

    @MainActor
    func updateViewSize(_ size: CGSize) async {
        switch channel {
        case .red:
            if size != model.gradationViewSizeRed {
                model.gradationViewSizeRed = size
            }
            
        case .green:
            if size != model.gradationViewSizeGreen {
                model.gradationViewSizeGreen = size
            }
            
        case .blue:
            if size != model.gradationViewSizeBlue {
                model.gradationViewSizeBlue = size
            }
        }
    }

プレビューの変更

ストアドプロパティが変わったので、Xcodeのライブプレビュー用のコードも変更します。

struct PickerGradationView_Previews: PreviewProvider {
    static var previews: some View {
        PickerGradationView(channel: .red,
                            model: ColorPickerModel())
            .padding()
    }
}

ColorPickerSubViewの対応

PickerGradationViewのストアドプロパティが変わったので、PickerGradationViewを使っているColorPickerSubViewもコード変更が必要です。

モデルプロパティの変更

ColorPickerChannelModelクラスとColorPickerGradationModelクラスを使ったプロパティを削除し、代わりにColorPickerModel.ChannelColorPickerModelクラスのインスタンスを代入するプロパティを追加します。

struct ColorPickerSubView: View {
    var channel: ColorPickerModel.Channel
    @ObservedObject var model: ColorPickerModel

チャネル名

チャネル名を上位から渡すコードになっていますが、チャネルが分かるので、channelプロパティによって文字列を変更するように実装を変更します。

    var channelName: String {
        switch channel {
        case .red:
            return "Red"
        case .green:
            return "Green"
        case .blue:
            return "Blue"
        }
    }
    
    var body: some View {
        VStack {
            HStack {
                Text("\(channelName): ")
                    .font(.title)

テキストフィールドのバインディング

TextFieldに渡すバインディングをchannelプロパティによって変更するようにコードを変更します。

    var fieldText: Binding<String> {
        switch self.channel {
        case .red:
            return self.model.fieldTextRed
        case .green:
            return self.model.fieldTextGreen
        case .blue:
            return self.model.fieldTextBlue
        }
    }
    
    var body: some View {
        VStack {
            HStack {
                Text("\(channelName): ")
                    .font(.title)
                TextField("", text: fieldText)
                    .textFieldStyle(.roundedBorder)
            }

PickerGradationViewのイニシャライザ変更

PickerGradationViewのストアドプロパティが変わり、イニシャライザに渡す引数が変わっています。

    var body: some View {
        VStack {
            HStack {
                Text("\(channelName): ")
                    .font(.title)
                TextField("", text: fieldText)
                    .textFieldStyle(.roundedBorder)
            }
            PickerGradationView(channel: channel, model: model)
        }
    }

ColorPickerSubView_Previewsの変更

ColorPickerSubViewのストアドプロパティが変わったので、ライブプレビュー用に生成するColorPickerSubViewのイニシャライザの引数を変更します。

struct ColorPickerSubView_Previews: PreviewProvider {
    static var previews: some View {
        ColorPickerSubView(channel: .red, model: ColorPickerModel())
            .padding()
    }
}

ColorPickerの対応

ColorPickerSubViewのストアドプロパティが変わったので、イニシャライザの引数の変更が必要です。また、モデルをColorPickerModelに変更するようにコード変更が必要です。

モデルの変更

以下のプロパティが不要になったので、削除します。

  • redChannel
  • redGradation
  • greenChannel
  • greenGradation
  • blueChannel
  • blueGradation

代わりにColorPickerModelクラスのインスタンスを代入するmodelプロパティを追加します。

struct ColorPicker: View {
    @StateObject var model: ColorPickerModel = ColorPickerModel()

colorプロパティの削除

ColorPicker.colorプロパティは不要になったので削除します。ColorPickerPreviewのイニシャライザに渡す色はColorPickerModel.colorプロパティを渡すように変更します。

                ColorPickerPreview(color: model.color)
                    .frame(width: 100, height: 100)

ColorPickerPreviewはバインディングを渡していたのですが、プレビュー側で変更することはないので、バインディングではなく、値渡しに変更します。

import SwiftUI

struct ColorPickerPreview: View {
    var color: Color
    
    var body: some View {
        GeometryReader { geometry in
            Path { path in
                path.addRect(CGRect(x: 0, y: 0, width: geometry.size.width, height: geometry.size.height))
            }
            .fill(color)
            
            Path { path in
                path.addRect(CGRect(x: 0, y: 0, width: geometry.size.width, height: geometry.size.height))
            }
            .stroke(lineWidth: 4)
        }
    }
}

struct ColorPickerPreview_Previews: PreviewProvider {
    static var previews: some View {
        ColorPickerPreview(color: .blue)
    }
}

ColorPickerSubViewのイニシャライザ変更

ColorPickerSubViewのストアドプロパティが変わったので、イニシャライザの変更が必要です。

struct ColorPicker: View {
    @StateObject var model: ColorPickerModel = ColorPickerModel()
        
    var body: some View {
        VStack {
            ColorPickerSubView(channel: .red, model: model)
            ColorPickerSubView(channel: .green, model: model)
            ColorPickerSubView(channel: .blue, model: model)
            VStack {
                Text("Preview")
                ColorPickerPreview(color: model.color)
                    .frame(width: 100, height: 100)
            }
            .padding()
        }
        .padding()
    }
}

動作テスト

動作テストをしてみましょう。Xcodeのライブプレビューがコード変更直後はおかしくなりました。

おかしい場合は、プロジェクトをクリーンしたり、実機やiOSシミュレータで実行し、Xcodeを再起動すると直ると思います。

ライブプレビュー

コードのダウンロード

今回の記事で作成したコードのダウンロードはこちらです。

連載目次

著書紹介

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

Akira Hayashi (林 晃)のアバター Akira Hayashi (林 晃) Representative(代表), Software Engineer(ソフトウェアエンジニア)

アールケー開発代表。Appleプラットフォーム向けの開発を専門としているソフトウェアエンジニア。ソフトウェアの受託開発、技術書執筆、技術指導・セミナー講師。note, Medium, LinkedIn
-
Representative of RK Kaihatsu. Software Engineer Specializing in Development for the Apple Platform. Specializing in contract software development, technical writing, and serving as a tech workshop lecturer. note, Medium, LinkedIn

目次