しめ鯖日記

swift, iPhoneアプリ開発, ruby on rails等のTipsや入門記事書いてます

UITableViewCellのtextLabelがdeprecatedになってたので調べてみた

iPhoneアプリの実装をしていたらtextLabelとdetailTextLabelがdeprecatedになっていたので詳しく調べてみました。
textLabelとdetailTextLabelはUITableViewCellの左右のラベルにアクセスできるプロパティーです。

警告は下の通りです。
代わりにUIListContentConfigurationを利用するように書かれていました。

Use UIListContentConfiguration instead, this property will be deprecated in a future release.

UIListContentConfigurationとは

UIListContentConfigurationはiOS14から登場した構造体でUITableViewCellのレイアウトを決めるものです。

使い方は下の通りです。
UIListContentConfigurationのインスタンスを作成してテキストなどを設定してからcontentConfigurationにセットしています。

var content = UIListContentConfiguration.cell()
content.text = "Test"
cell.contentConfiguration = content

実行すると左側にラベルが置かれたTableViewのCellが表示されます。

右側にもラベルを表示したい場合はvalueCellを使います。

var content = UIListContentConfiguration.valueCell()
content.text = "Test"
content.secondaryText = "Secondary"
cell.contentConfiguration = content

実行すると下のような表示になります。

subtitleCellを使うと下のような表示も可能です。

var content = UIListContentConfiguration.subtitleCell()
content.text = "Test"
content.secondaryText = "Secondary"
cell.contentConfiguration = content

テキストだけでなく画像の設置も可能です。

var content = UIListContentConfiguration.subtitleCell()
content.text = "Test"
content.secondaryText = "Secondary"
content.image = UIImage(named: "Image")
cell.contentConfiguration = content

文字のサイズなどはtextPropertiesを通して変更する事ができます。
他にも画像のプロパティーを操作したりNSAttributedStringをセットすることなどもできるようです。

var content = UIListContentConfiguration.subtitleCell()
content.textProperties.font = UIFont.boldSystemFont(ofSize: 20)
cell.contentConfiguration = content

古いやる方が非推奨になったので、今後はiOS13非対応のアプリを直すときはこちらを積極的に使おうと思いました。

Android用Realmの導入とbuild.gradleについてのメモ

今回はRealmの導入をしてみました。
それに合わせてbuild.gradleについて分からない所の簡単な調査も行いました。

インストール手順は公式サイトを参考にしました。

www.mongodb.com

インストール

まずは下のようにプロジェクトトップにあるbuild.gradleのpluginsにRealmを追加します。

plugins {
    id 'com.android.application' version '7.1.2' apply false
    id 'com.android.library' version '7.1.2' apply false
    id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
    id 'io.realm.kotlin' version '0.10.0' apply false // 追加
}

次はappフォルダのbuild.gradleのpluginsにもRealmを追加します。

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'io.realm.kotlin' // 追加
}

最後にdependencies同ファイルのdependenciesに下2行を追加します。

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-native-mt' // 追加
    implementation 'io.realm.kotlin:library-base:0.10.0' // 追加
    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.4.1'
    implementation 'com.google.android.material:material:1.5.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

入れ終わったら「Sync Project with Gradle Files」をしてインストール完了です。

動作確認

インストールが終わったら簡単に動かしてみます。
動作確認は下サイトを参考にしました。

www.mongodb.com

モデルの定義方法は下の通りです。

class Task : RealmObject {
    var name: String = "new task"
    var status: String = "Open"
}

データの取得は下の通りです。

val config = RealmConfiguration.Builder(schema = setOf(Task::class)).build()
val realm: Realm = Realm.open(config)
var tasks: RealmResults<Task> = realm.query<Task>().find()

データ保存は下の通りです。

val config = RealmConfiguration.Builder(schema = setOf(Task::class)).build()
val realm: Realm = Realm.open(config)
realm.writeBlocking {
    copyToRealm(Task().apply {
        name = "Do work"
        status = "Open"
    })
}

上記を動かしてみた所、無事に保存と取得ができていました。

build.gradleについてのメモ

build.gradleですが、プロジェクト直下とapp直下の2箇所にあります。
この2つの違いですが、プロジェクト直下は全サブプロジェクトやモジュール向け・app以下はサブプロジェクト(app以下)向けの処理を書くというもののようです。

次はプロジェクト直下build.gradleに記述した処理について見ていきます。
pluginsのブロックですが、こちらにはGradle依存関係を定義します。
定義は下のようにidメソッドを使って行います。

plugins {
    id 'io.realm.kotlin' version '0.10.0' apply false
}

applyについてはGradleのサイトを見た所、下のように書かれていました。
見た所、applyはプラグインの即時適用をするかどうかを決める事ができるもののようです。
即時適用しないことで「特定のサブプロジェクトにだけ導入する」といった事ができます。
ちなみにこれは除外しても問題なく動きました。

Where «plugin id» and «plugin version» must be constant, literal, strings and the apply statement with a boolean can be used to disable the default behavior of applying the plugin immediately (e.g. you want to apply it only in subprojects). No other statements are allowed; their presence will cause a compilation error.

Using Gradle Plugins

次はapp直下のbuild.gradleについて見ていきます。
まずは先程も出てきたpluginsについて調べます。

plugins {
    id 'io.realm.kotlin'
}

こちらについてはGradleのサイトに記述がありました。
プロジェクト直下のpluginsでapply falseをして、こちらにも記述する事でライブラリが使えるようになる模様です。

If you have a multi-project build, you probably want to apply plugins to some or all of the subprojects in your build, but not to the root project. The default behavior of the plugins {} block is to immediately resolve and apply the plugins. But, you can use the apply false syntax to tell Gradle not to apply the plugin to the current project and then use the plugins {} block without the version in subprojects' build scripts:

もしこの処理を書かないと下のようにapplyされていないというエラーになります。

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.myapp/com.example.myapp.MainActivity}: java.lang.IllegalStateException: Couldn't find companion object of class 'Task'.
A common cause for this is when the `io.realm.kotlin` is not applied to the Gradle module that contains the 'Task' class.

その後プロジェクト直下のbuild.gradleのapplyをtrueにしたのですが同じエラーが出ました。
どうやらモジュールのbuild.gradleでapplyしないと正しく動かない模様です。
もしくはキャッシュなどが残っているせいかもしれません。

plugins {
    id 'io.realm.kotlin' version '0.10.0' apply true
}

app直下の方でapply falseをつけた場合も同じエラーになります。
apply自体はしっかり動いている模様です。

plugins {
    id 'io.realm.kotlin' apply false
}

それとapp直下でversionを指定した場合、既にバージョンが指定されているエラーになります。
ただプロジェクト直下のidをコメントアウトした場合はエラーが出ずに正しく動かす事ができました。

plugins {
    id 'io.realm.kotlin' version '0.10.0'
}
Error resolving plugin [id: 'io.realm.kotlin', version: '0.10.0']
> Plugin request for plugin already on the classpath must not include a version

最後にapp直下build.gradleのdependenciesブロックについて見ていきます。
調べた所、コンパイル時や実行時に外部クラスを読み込むのに必要な処理のようです。
ただpluginsでのapplyとdependenciesの両方必要な理由が良く分かりませんでした。
この辺りはまた時間ある時に調べてみたいと思います。

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-native-mt'
    implementation 'io.realm.kotlin:library-base:0.10.0'
}

ビルド依存関係を追加する  |  Android デベロッパー  |  Android Developers

ちなみにこの処理をコメントアウトするとRealmのライブラリが読めないエラー(Unresolved reference: io)が出ます。

参考URL

ビルドを設定する  |  Android デベロッパー  |  Android Developers

Xcodeで単体テストとUIテストを試してみる

Xcode上でユニットテストとUIテストを作って動かしてみました。
ユニットテストは過去に下記事で試したんですが時間が経ったので再度試してみました。

llcc.hatenablog.com

ユニットテストを動かす

まずはプロジェクトを作成します。
作成時、Include TestsをONにします。

f:id:llcc:20220306114313p:plain

プロジェクトを作成すると下のようにテストファイルが作られます。

f:id:llcc:20220306114808p:plain

テスト用のターゲットも作られました。

f:id:llcc:20220306114829p:plain

テストはテストファイル左側のひし形ボタンをタップする事で実行できます。

f:id:llcc:20220306115612p:plain

左メニューのTest Navigatorから実行する事もできます。

f:id:llcc:20220306115714p:plain

実行すると結果が表示されます。

f:id:llcc:20220306115938p:plain

ユニットテストを追加する

次は実際にテストを書いていきます。
テストは下のようにXCTAssertから始まるメソッドを使って書いていきます。

class MyAppTests: XCTestCase {
    func testExample() throws {
        XCTAssertEqual(1, 1, "Test1")
        let value: Int? = nil
        XCTAssertNil(value, "Test2")
    }
}

テストの追加は下の通りです。
testから始まるメソッドを追加すれば自動的にテストとして認識されます。

class MyAppTests: XCTestCase {
    func testExample() throws {
        XCTAssertEqual(1, 1, "Test1")
        let value: Int? = nil
        XCTAssertNil(value, "Test2")
    }
    
    func testExample2() throws {
        XCTAssertEqual(1, 1, "Test3")
    }
}

measureというメソッドでメソッドの実行速度の記録も可能です。

class MyAppTests: XCTestCase {
    func testPerformanceExample() throws {
        self.measure {
            _ = (0...100000).reduce(0, { $0 + $1 })
        }
    }
}

実行後は下のような表示になります。

f:id:llcc:20220306133300p:plain

DBの作成など、テスト前の準備や片付けがある場合は下メソッドに記入していきます。

class MyAppTests: XCTestCase {
    override func setUpWithError() throws {
    }

    override func tearDownWithError() throws {
    }
}

UIテストを動かす

続けてUIテストも試していきます。
Main.storyboardにラベルとボタンを配置します。

f:id:llcc:20220306140546p:plain

ViewController.swift側にタップしたらラベルの文字を変える処理を追加してからStoryboardとの紐付けを行います。

class ViewController: UIViewController {
    @IBOutlet weak var label: UILabel!
    
    @IBAction func tapButton() {
        label.text = "Tapped"
    }
}

続けてテストを記述します。
まずはラベルが存在する事を確認します。
確認コードは下の通りです。

class MyAppUITests: XCTestCase {
    func testExample() throws {
        let app = XCUIApplication()
        app.launch()
        
        XCTAssertTrue(app.staticTexts["MyLabel"].exists)
    }
}

AccessibilityのIdentifierを設定しておけばそのIDでラベルを取得する事もできます。

XCTAssertEqual("MyLabel", app.staticTexts["ViewConrollerLabel"].label)

f:id:llcc:20220306163542p:plain

テスト実行すると無事に成功マークが出ました。

f:id:llcc:20220306141254p:plain

次はボタンタップのテストを追加します。
処理は下の通りです。

tapメソッドでタップ処理を行い、その後ラベルの値が変わったかどうかをテストしています。

class MyAppUITests: XCTestCase {
    func testExample() throws {
        let app = XCUIApplication()
        app.launch()
        
        XCTAssertTrue(app.staticTexts["MyLabel"].exists)
        app.buttons["MyButton"].tap()
        XCTAssertFalse(app.staticTexts["MyLabel"].exists)
        XCTAssertTrue(app.staticTexts["Tapped"].exists)
    }
}

下のようなコードで画面のスクリーンショットを取る事も可能です。

class MyAppUITests: XCTestCase {
    func testExample() throws {
        let app = XCUIApplication()
        app.launch()
        
        let attachment1 = XCTAttachment(screenshot: app.screenshot())
        attachment1.name = "Launched"
        attachment1.lifetime = .keepAlways
        add(attachment1)
        
        XCTAssertTrue(app.staticTexts["MyLabel"].exists)
        app.buttons["MyButton"].tap()
        XCTAssertFalse(app.staticTexts["MyLabel"].exists)
        XCTAssertTrue(app.staticTexts["Tapped"].exists)
        
        let attachment2 = XCTAttachment(screenshot: app.screenshot())
        attachment2.name = "Tapped"
        attachment2.lifetime = .keepAlways
        add(attachment2)
    }
}

スクリーンショットは左メニューのReport navigatorから見る事ができます。

f:id:llcc:20220306161911p:plain

【Kotlin】ViewModelとLiveDataでUIの更新を楽にする

ViewModelとLiveDataを試してみました。
ViewModelとLiveDataは個別に使うこともできるのですが、セットで使われる事が多いようなので一緒に試してみました。

ViewModelを利用する

まずはViewModelを使うためにモジュールのbuild.gradleに下の1行を追加します。

implementation "androidx.activity:activity-ktx:1.4.0"

ViewModelは下のように定義します。

class MyViewModel: ViewModel() {
    var test = 0
}

利用方法は下の通りです。
ActivityのviewModelsに委譲する形で変数を定義します。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val vm: MyViewModel by viewModels()
        Log.d("Test", "${vm.test}") // → 0
        vm.test = 1
        Log.d("Test", "${vm.test}") // → 1
        val vm2: MyViewModel by viewModels()
        Log.d("Test", "${vm2.test}") // → 1
    }
}

viewModelsはActivityの保持するプロパティーなので、下コードのvmとvm2の持つプロパティーは共通になります。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val vm: MyViewModel by viewModels()
        Log.d("Test", "${vm.test}") // → 0
        vm.test = 1
        val vm2: MyViewModel by viewModels()
        Log.d("Test", "${vm2.test}") // → 1
    }
}

LiveDataを利用する

LiveDataを使うと値の変更があった時、自動的に画面の表示も更新できるようになります。
まずはBindingを使うためにbuild.gradleでBindingを有効にします。

android {
    ...
    dataBinding {
        enabled = true
    }
}

次はLiveDataを使い、下のようにViewModelを定義します。

class MyViewModel: ViewModel() {
    val myProperty = MutableLiveData("test")
}

レイアウトファイルでViewModelの変数を使う方法は下の通りです。
variableタグで変数を定義して@{}の内部でそのプロパティーにアクセスしています。

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <variable name="vm" type="com.example.myapp.MyViewModel"/>
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{vm.myProperty}" />
    </LinearLayout>
</layout>

xmlへViewModelを渡す処理は下の通りです。
lifecycleOwnerがないとLiveDataがうまく動かないので注意が必要です。

class MainActivity : AppCompatActivity() {
    val vm: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding: ActivityMainBinding = DataBindingUtil.setContentView(
            this, R.layout.activity_main)
        binding.lifecycleOwner = this
        binding.vm = vm
    }
}

起動すると画面にmyPropertyの値が表示できています。

f:id:llcc:20220227154344p:plain

LiveDataの値は下のように書き換えます。 書き換えるとViewも即時更新されます。

vm.myProperty.value = "test2"

ビュー側の変更をViewModelに反映するには@{}ではなく@={}を利用します。

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <variable name="vm" type="com.example.myapp.MyViewModel"/>
    </data>

    <LinearLayout
        android:id="@+id/content_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <EditText
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@={vm.myProperty}" />
    </LinearLayout>
</layout>

Activity側でのプロパティー変更の監視方法は下の通りです。
myPropertyの値が変更された時に下の処理が呼ばれます。

vm.myProperty.observe(this) {
    Log.d("Test", it)
}

別のプロパティーに依存するLiveDataの定義ではMediatorLiveDataを使います。
例えば入力した文字数をリアルタイムで表示したい場合、下のようにtextCountを監視します。

ここで利用しているaddSourceメソッドはobserveと違い引数にLifecycleOwnerが不要です。
そのため下のようにViewModel内で処理を完結する事ができます。

class MyViewModel: ViewModel() {
    val myProperty = MutableLiveData("test")
    val textCount = MediatorLiveData<Int>()

    init {
        val observer = Observer<String> {
            textCount.value = myProperty.value?.count() ?: 0
        }
        textCount.addSource(myProperty, observer)
    }
}

レイアウトは下のように記述します。

<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <variable name="vm" type="com.example.myapp.MyViewModel"/>
    </data>

    <LinearLayout
        android:id="@+id/content_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <EditText
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@={vm.myProperty}" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{vm.textCount.toString()}" />
    </LinearLayout>
</layout>

Kotlinの委譲用のキーワードのbyを試してみる

Kotlinのbyというキーワードが分からなかったので調べてみました。

byを使ってみる

byとは委譲に使うキーワードです。
実際に動かしながら委譲やbyについて見ていきます。

まずはInterfaceとInterfaceを適用したクラスを作成します。

interface TestInterface {
    val property: String
    fun method(): Int
}

class MyClass1: TestInterface {
    override val property = "1"
    override fun method(): Int { return 1 }
}

次に同じInterfaceを適用したクラスを作成します。
このクラスは初期化時にMyClass1を受け取り、内部ではMyClass1のメソッドやプロパティーを呼び出します。

class MyClass2(private val myClass1: MyClass1): TestInterface {
    override val property = myClass1.property
    override fun method(): Int { return myClass1.method() }
}

実際に動かしてみるとMyClass1のプロパティーが呼ばれている事が分かります。
このように外部のクラスのプロパティーやメソッドを利用する事を委譲といいます。
byというキーワードは委譲を簡単にするキーワードです。

Log.d("Test", "${MyClass2(MyClass1()).property}") // → 1

byを使ってMyClass2を書き直すと下のようになります。
これでTestInterfaceに関する処理はすべてmyClass1のものを呼び出すようになります。

class MyClass2(private val myClass1: MyClass1): TestInterface by myClass1

下のように一部の処理だけは委譲しない事も可能です。

class MyClass2(private val myClass1: MyClass1): TestInterface by myClass1 {
    override val property = "2"
}

Log.d("Test", "${MyClass2(MyClass1()).property}") // → 2

複数のInterfaceを適用して委譲する場合はそれぞれにbyを付ける必要があります。

interface TestInterface {
    val property: String
    fun method(): Int
}

interface TestInterface2 {
    val property2: String
}

class MyClass1: TestInterface, TestInterface2 {
    override val property = "1"
    override fun method(): Int { return 1 }
    override val property2 = "property2"
}

class MyClass2(private val myClass1: MyClass1): TestInterface by myClass1, TestInterface2 by myClass1

下のようにインスタンスとInterfaceの型があってない場合はエラーになります。
もしTestInterface2用のプロパティーを実装してあってもエラーです。

class MyClass1: TestInterface {
    override val property = "1"
    override fun method(): Int { return 1 }
    val property2 = "property2"
}

class MyClass2(private val myClass1: MyClass1): TestInterface2 by myClass1 // → Type mismatch.エラー

下のように委譲先(MyClass1)のプロパティーの値を変えるとMyClass2のproperty呼び出し時の値も変わります。

interface TestInterface {
    val property: String
}

class MyClass1: TestInterface {
    override var property = "1"
}

class MyClass2(private val myClass1: MyClass1): TestInterface by myClass1

val myClass1 = MyClass1()
Log.d("Test", "${MyClass2(myClass1).property}") // → 1
myClass1.property = "2"
Log.d("Test", "${MyClass2(myClass1).property}") // → 2

委譲プロパティについて

上記ではクラスに対してbyを使いましたがプロパティーに使う事もできます。
具体的な使い方は下の通りです。
myPropertyのgetterとsettterをDelegateClassに委譲しています。

Log.d("Test", "${MyClass().myProperty}") // → 1
MyClass().myProperty = "2"

class DelegateClass {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return "1"
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        Log.d("Test", "$thisRef") // → com.example.myapp.MyClass@4dea06e
        Log.d("Test", "$value") // → 2
        Log.d("Test", "${property.name}") // → myProperty
    }
}

class MyClass {
    var myProperty: String by DelegateClass()
}

同じDelegateインスタンスを使い回す事で、下のように値を複数クラスで共有する事もできます。

class DelegateClass {
    var myProperty = ""

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return myProperty
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        myProperty = value
    }
}

class MyClass(delegate: DelegateClass) {
    var myProperty: String by delegate
}

val delegate = DelegateClass()
Log.d("Test", "${MyClass(delegate).myProperty}") // → ""
MyClass(delegate).myProperty = "2"
Log.d("Test", "${MyClass(delegate).myProperty}") // → 2

Mapとbyについて

byを使うとMapの中の値にアクセスする事も可能です。
具体的には下の通りです、JSONデータの展開などで活躍しそうです。

class MyClass(delegate: Map<String, String>) {
    val myProperty: String by delegate
}

Log.d("Test", "${MyClass(mapOf("myProperty" to "1")).myProperty}")

Mapに該当のキーがない場合、NoSuchElementExceptionエラーになります。

class MyClass(delegate: Map<String, String>) {
    val myProperty: String by delegate
}

Log.d("Test", "${MyClass(mapOf("myProperty2" to "1")).myProperty}")

MutableMapを使う事で値のセットもできるようになります。

class MyClass(delegate: MutableMap<String, String>) {
    var myProperty: String by delegate
}

val map = mutableMapOf("myProperty" to "1")
Log.d("Test", "${MyClass(map).myProperty}") // → 1
MyClass(map).myProperty = "2"
Log.d("Test", "${MyClass(map).myProperty}") // → 2

Xcodeからxclocのエクスポートとインポートを試す

Xcodeからxclocのエクスポートとインポートを試してみました。
xclocとは翻訳ファイル(Localization.string)などを編集しやすい形にしたファイルです。

こちらのフォーマットはxliffを改善したもので、Xcode10から使えるようになりました。

国際化自体については下記事でまとめたのでよろしければご参照下さい。

llcc.hatenablog.com

xclocのエクスポートとインポート

まずは下のようにローカライズしたStringファイルを作ります。

f:id:llcc:20220213121036p:plain

各ファイル下のように1行だけ追加しました。

CFBundleDisplayName = "アプリ名_英語";
"testKey" = "英語";

Xcodeも国際化をしておきます。

f:id:llcc:20220213122200p:plain

次にXcodeのProductのExport Localizations…から書き出しをします。

f:id:llcc:20220213121202p:plain

次画面で保存先を指定したら保存します。

f:id:llcc:20220213121305p:plain

保存すると下のように各言語用のファイルが作られます。

f:id:llcc:20220213122019p:plain

ファイルを開くと下のように各ファイルの英語・日本語がある画面になります。

f:id:llcc:20220213122953p:plain

試しにここの日本語部分を変更してみます。

f:id:llcc:20220213123011p:plain

変更したらImport Localizations...からインポートしてみます。

f:id:llcc:20220213122441p:plain

インポート時は下のように差分を見る事ができます。

f:id:llcc:20220213123123p:plain

インポート完了すると下のようにプロジェクト側も変更されます。

f:id:llcc:20220213123156p:plain

xclocの中身

xclocですが中身は下のような構成になっています。
Source ContentsにはstringファイルやStoryboardなど元ファイルが入っています。
Localized Contentsには編集用のxliffファイルが入っています。

f:id:llcc:20220213133617p:plain

xliffファイルの中身は下のようになっています。

<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
  <file original="MyApp/Base.lproj/Main.storyboard" source-language="en" target-language="fr" datatype="plaintext">
    <header>
      <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.2.1" build-num="13C100"/>
    </header>
    <body>
      <trans-unit id="reG-Hn-ghY.text" xml:space="preserve">
        <source>Hello world</source>
        <target>Hello world</target>
        <note>Class = "UILabel"; text = "Hello world"; ObjectID = "reG-Hn-ghY";</note>
      </trans-unit>
    </body>
  </file>
  <file original="MyApp/en.lproj/InfoPlist.strings" source-language="en" target-language="fr" datatype="plaintext">
    <header>
      <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.2.1" build-num="13C100"/>
    </header>
    <body>
      <trans-unit id="CFBundleDisplayName" xml:space="preserve">
        <source>アプリ名_英語</source>
        <target>アプリ名_フランス語</target>
        <note>InfoPlist.strings
  MyApp

  Created by XXXX on 2022/02/13.</note>
      </trans-unit>
      <trans-unit id="CFBundleName" xml:space="preserve">
        <source>MyApp</source>
        <note>Bundle name</note>
      </trans-unit>
    </body>
  </file>
  <file original="MyApp/en.lproj/Localizable.strings" source-language="en" target-language="fr" datatype="plaintext">
    <header>
      <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="13.2.1" build-num="13C100"/>
    </header>
    <body>
      <trans-unit id="testKey" xml:space="preserve">
        <source>英語</source>
        <target>フランス語</target>
        <note>Localizable.strings
  MyApp

  Created by XXXX on 2022/02/13.</note>
      </trans-unit>
    </body>
  </file>
</xliff>

Swiftでの絵文字判定について調べる

Swiftで文字列の中の絵文字を判定できないかを調べてみました。

結論

下のようにisEmojiPresentation、isEmojiModifier、isEmojiModifierBaseを使う事で判定できるようです。
各メソッドの内容は下部の調査内容に記載しました。

let inputText = "Some 🕵string 😂😂😂 with 👹👹 👹 emoji 🖐"
print(inputText.unicodeScalars.filter { !$0.properties.isEmojiPresentation && !$0.properties.isEmojiModifier && !$0.properties.isEmojiModifierBase }.reduce("", { $0 + String($1) })))

調査経緯

絵文字について調べたところisEmojiPresentationというメソッドがあったので試してみました。

Swift - Replacing emojis in a string with whitespace - Stack Overflow

Apple Developer Documentation

このメソッドを使って絵文字を除外してみました。
isEmojiPresentationはUnicode.Scalar.Propertiesクラスのメソッドなので文字列からPropertiesを取り出して再度結合する必要があります。

let inputText = "Some 🖐string 😂😂😂 with 👹👹 👹 emoji 🖐"
let textWithoutEmoij = inputText.unicodeScalars.filter { !$0.properties.isEmojiPresentation }.reduce("") { $0 + String($1) }
print(textWithoutEmoij)

結果は下の通りです。
🖐だけは除外することができませんでした。

f:id:llcc:20220207105126p:plain

除外できない原因を探る為、unicodeScalarsで取得したScalarを一つ一つ出力してみました。

let inputText = "Some 🖐string 😂😂😂 with 👹👹 👹 emoji 🖐"
inputText.unicodeScalars.forEach { print($0.properties) }

結果は下の通りです。
手の絵文字も正しく取得できているようでした。

f:id:llcc:20220207105349p:plain

良く分からないのでisEmojiPresentationのメソッドの説明を確認しました。
それによるとEmoji_Presentationに含まれる絵文字ならtrueになるようです。

A Boolean value indicating whether the scalar is one that should be rendered with an emoji presentation, rather than a text presentation, by default.

Scalars that have default to emoji presentation can be followed by U+FE0E VARIATION SELECTOR-15 to request the text presentation of the scalar instead. Likewise, scalars that default to text presentation can be followed by U+FE0F VARIATION SELECTOR-16 to request the emoji presentation.

This property corresponds to the "Emoji_Presentation" property in the [Unicode Standard](http://www.unicode.org/versions/latest/).

🖐はEmoji_Presentationにない可能性も考えてUnicodeのサイトを確認したのですが🖐は含まれていました。

Emoji Proposals, v14.0

f:id:llcc:20220207111310p:plain

🖐が異字体なこと(🖐🏾などのカラーバリエーションがある事)が原因の可能性も考えて他の異字体絵文字でも試してみました。

print("🕵".unicodeScalars)
print("🕵".unicodeScalars.first?.properties.isEmojiPresentation)

試した所、この絵文字もisEmojiPresentationで判別できない事が分かりました。
異字体はisEmojiPresentationを使った判別は難しいようです。

f:id:llcc:20220207114532p:plain

ドキュメント見ていた所、isEmojiModifierBaseとisEmojiModifierというメソッドがあったので動かしてみました。
検証コードは下の通りです。

print("😂".unicodeScalars)
print("😂".unicodeScalars.map { $0.properties.isEmojiPresentation })
print("😂".unicodeScalars.map { $0.properties.isEmojiModifier })
print("😂".unicodeScalars.map { $0.properties.isEmojiModifierBase })
print("🕵".unicodeScalars)
print("🕵".unicodeScalars.first?.properties.isEmojiPresentation)
print("🕵".unicodeScalars.map { $0.properties.isEmojiModifier })
print("🕵".unicodeScalars.map { $0.properties.isEmojiModifierBase })
print("🖐".unicodeScalars)
print("🖐".unicodeScalars.first?.properties.isEmojiPresentation)
print("🖐".unicodeScalars.map { $0.properties.isEmojiModifier })
print("🖐".unicodeScalars.map { $0.properties.isEmojiModifierBase })
print("🖐🏾".unicodeScalars)
print("🖐🏾".unicodeScalars.first?.properties.isEmojiPresentation)
print("🖐🏾".unicodeScalars.map { $0.properties.isEmojiModifier })
print("🖐🏾".unicodeScalars.map { $0.properties.isEmojiModifierBase })

結果は下の通りです。
異字体はisEmojiModifierBaseがtrueになっていました。
🖐🏾の結果を見た限り、異字体の色を決める部分はisEmojiModifierで判定できるようです。

f:id:llcc:20220207121409p:plain

上記メソッドを使って下のようにコードを修正しました。

let inputText = "Some 🕵string 😂😂😂 with 👹👹 👹 emoji 🖐"
print(inputText.unicodeScalars.filter { !$0.properties.isEmojiPresentation && !$0.properties.isEmojiModifier && !$0.properties.isEmojiModifierBase }.reduce("", { $0 + String($1) })))

動かしてみた所、すべての絵文字を除外できるようになりました。

f:id:llcc:20220207121224p:plain

余談ですがUnicode.Scalar.PropertiesにはisEmojiというメソッドもあります。
こちらは異字体も絵文字として判定するのですが、0などの絵文字以外も絵文字扱いしてしまうので使用しませんでした。

print("0".unicodeScalars.map { $0.properties.isEmojiPresentation }) // true