🎨

文字列で柔軟に書式を指定できるライブラリ Aizome を作った

に公開

アプリの多言語対応をしている際、一部の文字だけ色を変えたり強調するいったスタイルを適用する際に迷うことがあります。
新しく作ったライブラリ Aizome では、ネイティブアプリ向けの文字列に対する書式をHTMLのようにマークアップできるようになります。
書式の適用箇所の指定を文言リソースに移譲することで、自由度の高いスタイル表現をアプリ側への追加実装を必要とせずに実現します。

とりあえずこれを見てほしい

「あーなるほどね」って感じのスクリーンショットを置いておきます。


解決したい問題

SwiftUIでは、AttributedStringを使うことでテキストの一部分だけ強調したり色を変えることができます。AndroidのJetpack ComposeでもAnnotatedStringで同じことができます。
(この後、これらを「書式付き文字列」と呼ぶことにします)

var text: AttributedString = {
    var string = AttributedString()
    
    var registerFragment = AttributedString("会員登録")
    registerFragment.font = .system(size: 16, weight: .bold)
    registerFragment.foregroundColor = .red
    string.append(registerFragment)
    
    var afterFragment = AttributedString("を完了してください")
    string.append(afterFragment)
    
    return string
}()

しかし、これは文章の内容にもかかわらずアプリケーション側のコードを必要としてしまいます。
特に多言語対応する際に、スタイルの変わる部分で文言を分割して定義し、アプリケーション側のコードで分割された文言にスタイルを当ててから結合するようなアプローチをとる必要があります。

// 日本語では [会員登録]を完了してください
// 英語では Please complete [register]
// 
// [] の部分を強調したいが、日本語では強調する前の文章がなく、逆に英語では強調する後の文章がないため
// ローカライズ文言管理に空文字列をわざわざ定義しておく必要がある

var text: AttributedString = {
    var string = AttributedString()
        
    var beforeFragment = AttributedString(R.string.message_please_register_before()) // ja: "", en: "Please complete "
    string.append(beforeFragment)

    var registerFragment = AttributedString(R.string.message_please_register_regisiter()) // ja: "会員登録", en: "register"
    registerFragment.font = .system(size: 16, weight: .bold)
    registerFragment.foregroundColor = .red
    string.append(registerFragment)
    
    var afterFragment = AttributedString(R.string.message_please_register_ater()) // ja: "を完了してください", en: ""
    string.append(afterFragment)
    
    return string
}()

しかし、この方法では文言の内容に合わせてアプリケーションの実装を変える必要があり、言語によってはスタイルを変えたい場所が複数になったり、場所が大きく変わったりする可能性もあります。

更に、書式付き文字列を使って変数的に一部分のテキストを変えたい場合もあります。例えば「お荷物は x月y日 に到着する予定です」などといったものがあります。
String.formatのようなプレースホルダーを使えれば簡単にできますが、これもその度に書式付き文字列を組み立てる必要があって手間になります。

Aizome による解決

そこで開発したのがAizomeです。
iOS向けのSwiftUI版、Android/Compose Multiplatform向けのCompose版があります。

https://github.com/iseebi/aizome-swiftui
https://github.com/iseebi/aizome-compose

Aizome は、文言の中でスタイルや変数展開をマークアップとして記述できるライブラリです。
事前にスタイルを定義することで、そのスタイルをHTML/XMLのようなタグでマークアップすることができます。
例えば以下のように、翻訳文の中にスタイルや変数を埋め込むことができます。

<blue>%@</blue>にログインしてください

このように、定義したスタイルの名前でタグをマークアップした文言を Aizome に渡すことで、スタイルとプレースホルダーが適用された書式付き文字列を生成できます。

スタイルの定義

スタイルはタグ名とそれに対応するStringStyleを使って事前に定義しておきます。
アプリケーショングローバルなスタイル定義をAizome.defineDefaultStylesで定義しておくことのほか、使用箇所で都度定義を渡すこともできます。

SwiftUI版では基本的なスタイリングができる BasicStringStyle と任意の AttributeContainer を使用できる AttributeContainerStringStyle があります。Compose版では SpanStyle を指定できる SpanStyleStringStyle があります。また StringStyle を独自に定義することもできます。

Aizome.defineDefaultStyles([
    "blue": BasicStringStyle(color: .blue),
    "bold": BasicStringStyle(font: .system(size: 14, weight: .bold))
])
Aizome.instance.setDefaultStyles(
    mapOf(
        "bold" to SpanStyleStringStyle(SpanStyle(fontWeight = FontWeight.Bold)),
        "blue" to SpanStyleStringStyle(SpanStyle(color = Color.Blue)),
    ),
)

このような定義で、文言中から <blue>, <bold> のようなタグが利用可能になり、対応するStringStyleで装飾されます。

スタイル付き文言を表示する

マークアップを含む文字列はstyledString関数で書式付き文字列にできます。

struct ContentView: View {
    var body: some View {
	    VStack(alignment: .leading, spacing: 12) {
	    	Text(styledString("<blue>こんにちは</blue> <bold>世界</bold>"))
	    }
    }
}
@Composable
fun ContentView() {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Text(styledString("<blue>こんにちは</blue> <bold>世界</bold>"))
    }
}

プレースホルダを使った文言のフォーマット

String(format:) / String.format と似た形式でプレースホルダーを含むスタイル付きの文字列を組み立てることができます。
プレースホルダでの利用は複数回の使用が想定されるので、StyledStringFormat というフォーマッタを一度作っておく必要があります。
Formatterのインスタンス内にパース結果を保持しており、効率よくフォーマットさせることができます。
また、同じフォーマットの文章が出てくる場合はこのフォーマッタのインスタンスを使い回すこともできます。

複数の引数や、位置指定、数値フォーマットにも対応しています。
たとえば <bold>%2$@</bold> さんのスコアは <blue>%1$04d</blue> 点です のように、位置指定とゼロ埋めフォーマットも同時に利用できます。

struct ContentView: View {
    let formatter = StyledStringFormat("<blue>%@</blue>にログインしてください")

    var body: some View {
	    VStack(alignment: .leading, spacing: 12) {
	    	Text(formatter.format(arguments: ["taro@example.com"]))
	    }
    }
}
@Composable
fun ContentView() {
    val formatter = StyledStringFormat("<blue>%@</blue>にログインしてください")

    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Text(formatter.format("taro@example.com"))
    }
}

多様な対応プラットフォーム

SwiftUI版とCompose版を揃えたことで、多くのプラットフォームに対応することができました。

SwiftUI版はiOSだけでなく、macOS(native/Catalyst)、tvOS、watchOSに対応しています。

Compose版は、AndroidのJetpack Composeだけでなく、Compose MultiplatformでiOS, Androidにも対応しています。
Jetpack ComposeでもCompose Multiplatformでも全く同じように使うことができ、KotlinワンソースでAndroid/iOSに対応したアプリを書くことができます。
いまは入れていませんが、DesktopやWebにもすぐ対応できると思います。

hoshiとの組み合わせで更に便利に

すでに公開している、ローカライズ文言管理ツールhoshiでは、1つの定義からiOS/Android向けの両方の定義を書き出すことができるので、組み合わせることでアプリの多言語対応がよりやりやすくなると思います。

https://github.com/iseebi/hoshi
https://zenn.dev/iseebi/articles/hoshi_introduction

実装にあたって

try! Swift Tokyo 2025 に参加して、Swift書きたい欲が高まりすぎた折りに、多言語対応文字列のスタイリングの問題を思い出して、2日目の夕方くらいからSwiftUI版をひたすら書き続けました。
ちょうどtry! Swiftクロージングの手前の時間にできあがって、SwiftUI版をGitHubで公開しました。

このライブラリはAndroid版も揃っていてこそ意味があるので、SwiftUI版ができたあとにCompose版にもすぐに手をつけました。
ちょうどプライベートのプロジェクトでCompose Multiplatformを触っていたこともあり、両対応したライブラリを作りたいと思い、調べながら作ってみました。
Jetpack Compose/Compose Multiplatform両対応ライブラリの開発については、あまり情報がなかったのですが、昨今はChatGPTに聞いたら教えてくれるので便利ですね。
結果として、Swift書きたい欲を満たすはずが、Kotlinのほうにかけた時間が多くなってしまいました。

Discussion