22
15

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.

SwiftのKeyPathを使いこなす

Last updated at Posted at 2023-04-16

今回はKeyPathを使って色々なものを便利にしていきたいと思います。

KeyPathとは

KeyPathとは、動的にプロパティにアクセスできるようにするものです。
\.nameのようなSwiftでよくみるアレです。
詳しい説明は省きますが、こんな感じでプロパティの値を読み取ったり書き込んだりすることができます。

struct Dog {
    var name: String
    var age: Int
}

var dog = Dog(name: "いぬ", age: 5)

// 値の読み取り
let keyPath: KeyPath<Dog, String> = \.name
print(dog[keyPath: keyPath]) // いぬ

// 書き込み
let writableKeyPath: WritableKeyPath<Dog, String> = \.age
dog[keyPath: writableKeyPath] = 10
print(dog.age) // 10

今回はこの機能を使って便利なものを作っていきたいと思います。

手始めにKeyPathでmapを自作してみる

SwiftのmapにはKeyPathを使った便利な書き方が可能です。

dogs.map(\.name)

これを自作するとこのようになります。↓

extension Sequence {
    func myMap<Value>(_ keyPath: KeyPath<Element, Value>) -> [Value] {
        map { $0[keyPath: keyPath] }
    }
}

keyPathを使うことで引数で指定したpropertyのみの配列に変換することができます。

そしてmapと同じように使うことができました!

let dogs = [
    Dog(name: "たけし", age: 1),
    Dog(name: "たかはし", age: 2),
    Dog(name: "たろう", age: 3)
]
print(dogs.myMap(\.name))
// ["たけし", "たかはし", "たろう"]

AttributedStringを便利にしてみる

AttributedStringとは、文字の色やfontなどを部分的に変えたりできる優れもの。

let text = "大小"
let attributedString = AttributedString(text)
if let range = attributedString.range(of: "大") {
    attributedString[range].font = .boldSystemFont(ofSize: 30)
}

↑これでのみが大きくなります。
こんな感じで簡単に一部分だけfontを変えることができますが、以下のようにViewBuilder内にこれを書くとエラーになってしまいます。

var body: some View {
    let text = "大小"
    let attributedString = AttributedString(text)
    if let range = attributedString.range(of: "大") {
        attributedString[range].font = .boldSystemFont(ofSize: 30)
    }
    Text(attributedString)
}

これでは不便なので、
この処理をKeyPathを使ってString型のメソッドにする。↓

extension String {
    func attribute<Value>(
        range: String,
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        var attributedString = AttributedString(self)
        if let range = attributedString.range(of: range) {
            attributedString[range][keyPath: keyPath] = value
        }
        return attributedString
    }
}

すると、このように書くことができます。

var body: some View {
    Text(
        "大小"
            .attribute(range: "大", keyPath: \.font, value: .boldSystemFont(ofSize: 30))
    )
}

これでエラーがでずに済みました。
ですが、これだと"小"をもっと小さくしたい場合にメソッドチェーンできません。
なのでAttributedStringに以下のメソッドを付け足します。

extension AttributedString {
    func attribute<Value>(
        range: String,
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        var copy = self
        if let range = copy.range(of: range) {
            copy[range][keyPath: keyPath] = value
        }
        return copy
    }
}

すると、こんな感じでメソッドチェーンできるようになりました!

var body: some View {
    Text(
        "大小"
            .attribute(range: "大", keyPath: \.font, value: .boldSystemFont(ofSize: 30))
            .attribute(range: "小", keyPath: \.font, value: .systemFont(ofSize: 10))
    )
}

つぎはこんな感じでページの表示をする場合を考えてみます。
スクリーンショット 2023-04-16 17.32.47.png
この場合、以下のようなコードになります。

var body: some View {
    Text(
        "1 ~ 2 ページ"
            .attribute(range: "1", keyPath: \.font, value: .boldSystemFont(ofSize: 30))
            .attribute(range: "2", keyPath: \.font, value: .boldSystemFont(ofSize: 30))
            .attribute(range: "~", keyPath: \.foregroundColor, value: .red)
            .attribute(range: "ページ", keyPath: \.foregroundColor, value: .red)
    )
}

12, ~ページとで同じAttributeをつけているのにも関わらず、同じメソッドを複数書かなければならないため、冗長なコードです。
そこで、StringとAttributedStringにこのようなメソッドを付け足しました。

extension String {
    func attribute<Value>(
        ranges: [String],
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        ranges.reduce(AttributedString(self)) { attributedString, element in
            attributedString.attribute(range: element, keyPath: keyPath, value: value)
        }
    }
}

extension AttributedString {
    func attribute<Value>(
        ranges: [String],
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        ranges.reduce(self) { attributedString, element in
            attributedString.attribute(range: element, keyPath: keyPath, value: value)
        }
    }
}

rangeを配列にすることで、同じAttributeをかける文字でメソッドをまとめることができました!!
以下が修正後のコードです。

var body: some View {
    Text(
        "1 ~ 2 ページ"
            .attribute(ranges: ["1", "2"], keyPath: \.font, value: .boldSystemFont(ofSize: 30))
            .attribute(ranges: ["~", "ページ"], keyPath: \.foregroundColor, value: .red)
    )
}

これでかなり便利にAttributedStringを扱うことができました!

全体のコード

コピペしてお使いください。

extension String {
    func attribute<Value>(
        range: String,
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        var attributedString = AttributedString(self)
        if let range = attributedString.range(of: range) {
            attributedString[range][keyPath: keyPath] = value
        }
        return attributedString
    }

    func attribute<Value>(
        ranges: [String],
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        ranges.reduce(AttributedString(self)) { attributedString, element in
            attributedString.attribute(range: element, keyPath: keyPath, value: value)
        }
    }
}

extension AttributedString {
    func attribute<Value>(
        range: String,
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        var copy = self
        if let range = copy.range(of: range) {
            copy[range][keyPath: keyPath] = value
        }
        return copy
    }

    func attribute<Value>(
        ranges: [String],
        keyPath: WritableKeyPath<AttributedSubstring, Value>,
        value: Value
    ) -> AttributedString {
        ranges.reduce(self) { attributedString, element in
            attributedString.attribute(range: element, keyPath: keyPath, value: value)
        }
    }
}

その他

配列から特定のプロパティの重複を削除することができるKeyPathを使用した関数も作成しました。

まとめ

これでAttributedStringがより便利になった。
KeyPathでもっと面白そうなことができそうですね。

参考文献

22
15
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
22
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?