しめ鯖日記

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

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

AndroidのXMLのIDの+について調べる

Androidアプリ開発で出てくるIDの+の意味について調べてみました。

<EditText android:id="@+id/editText" />

調べた所+を付けるのはIDを自動定義するためのようです。
+なしだと参照するだけなのですが、+ありだと新規定義&参照してくれます。

参考にしたのは下記事です。

android - @+id/とはなんですか? - スタック・オーバーフロー

実際にプラスを取るとエラーになります。

<EditText android:id="@id/editText" />

エラー内容は下のように「IDが見つからない」というものです。

error: resource id/editText (aka com.example.myapp:id/editText) not found.

IDが見つからないと言われたので試しに自分で定義してみました。
値は適当な数字を入れています。

<resources>
    <id name="editText">347284929378</id>
</resources>

実行すると下のようなエラーになりました。
どうやらIDは空かリソースの参照にする必要があるようです。

<id> inner element must either be a resource reference or empty.

空にしたら無事に動かすことができました。
先程のレイアウト側で置きていた「IDがない」というエラーもおきなくなりました。

<resources>
    <id name="editText" />
</resources>

上記のようにidを定義した状態で+idを使ってもエラーにはならないようです。

<EditText android:id="@+id/editText" />

それと複数箇所でIDの定義をすると警告が出ます。

<EditText android:id="@+id/editText" />
<EditText android:id="@+id/editText" />

警告内容は下のとおりです。

Duplicate id @+id/cbDrink, already defined earlier in this layout

こちらですが片方の+を取れば警告が出なくなりました。

<EditText android:id="@+id/editText" />
<EditText android:id="@id/editText" />

国際化対応メモ

アプリの国際化をしたのでその時の対応内容についてまとめました。

文字列の取得はR.swiftを利用する

R.swiftとは画像の取得・Storyboardの取得などを文字列を使わずに行えるようになるライブラリです。
導入する事で下のようにStoryboardやUIImageを取得できるようになります。

// StroryboardのViewControllerの取得
let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? MyViewController
// ↓ のように書ける
let viewController = R.storyboard.main.initialViewController

// 画像の取得
let image = UIImage(named: "MyImage")
// ↓ のように書ける
let image = R.image.myImage

github.com

今回は文字列の取得にR.swiftを使いました。
R.swiftの導入をする事で下のように書くことができます。

NSLocalizedString("Main Close", comment: "")
// ↓ のように書ける
R.string.localizable.mainClose()

Storyboardの国際化は利用しない

翻訳ファイルを1箇所にまとめたかったので今回はStoryboardの国際化機能を利用しませんでした。
翻訳はLocalizable.stringsにまとめて下のように各画面のviewDidLoadなどでセットする方針にしています。

title = R.string.localizable.mainTitle()
bodyLabel.text = R.string.localizable.mainBody()

コードを各量は増えるのですが一箇所にまとまっているおかげで翻訳作業がスムーズでした。

各言語用のSchemeを作成する

今回は下のように各言語用のSchemeを作成してテストしました。
下の場合、MyApp(en)を選んでアプリ起動すると英語がデフォルトになります。

f:id:llcc:20220127160423p:plain

Schemeの作成は下のManage Schemesから行います。

f:id:llcc:20220127160210p:plain

下画面で既存Schemeを複製します。

f:id:llcc:20220127160231p:plain

作成画面で名前とApp Languageを選択すれば完了です。

f:id:llcc:20220127160254p:plain

Schemeを作る事で端末の言語を変えずに翻訳のテストをすることが可能です。
ただアプリ名やiOSが出している言語(App Tracking Transparencyアラートなど)は翻訳されないので注意が必要です。

NSShowNonLocalizedStringsオプションについて

XcodeSchemeではNSShowNonLocalizedStrings YESというオプションをセットする事が可能です。
これをセットすると未翻訳の文字列が大文字になるので翻訳漏れを発見しやすくなります。

ただ、その言語でアプリをしないと発見できない(英語だけ翻訳漏れしてい場合は英語でアプリを起動しないと変化がない)ので気休め程度に考えておくのが良さそうです。

f:id:llcc:20220127160934p:plain

画像や色(Assets)の国際化について

XcodeではAssetsファイルの国際化も対応しています。

f:id:llcc:20220213135855p:plain

国際化は画面右下から言語を選択する事で実施可能です。

f:id:llcc:20220213144030p:plain

選択すると下のように選択言語用の画像を設定できるようになります。

f:id:llcc:20220213144108p:plain

参考URL

iOSアプリケーションの国際化と地域化 - クックパッド開発者ブログ

iOSアプリの国際化対応の勘所とTips集(Swift版) - Qiita

iOS15のAppearanceについて整理する

iOS15でAppearanceの仕様が変わったので詳しく調べてみました。

Appearanceとは

AppearanceとはUIKitの機能の一つで、デフォルトの色やフォントなどを設定できます。

例えばアプリ起動時に下の処理を入れるとナビゲーションバーが青色になります。

UINavigationBar.appearance().barTintColor = UIColor(red: 220/255, green: 0/255, blue: 0/255, alpha: 1)

f:id:llcc:20220122105831p:plain

これ以外にもUILabelのデフォルト文字サイズやUISwitchのデフォルトの色など様々な設定が可能です。

iOS15での変更点

iOS15では既存の方法ではUINavigationBarの色を正しく変更できません。
スクロール位置が一番上のときは下のように真っ白、それ以外の時は青色になります。

f:id:llcc:20220122110144p:plain

iOS15からは下のようにstandardAppearanceとscrollEdgeAppearanceにセットする必要があります。
barTintColorがbackgroundColorに変わっていたりするので、セットする際は注意が必要です。

let appearance = UINavigationBarAppearance()
appearance.backgroundColor = UIColor(red: 220/255, green: 0/255, blue: 0/255, alpha: 1)
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance

上の2つappearanceですが登場はiOS13です。
iOS13とiOS14ではstandardAppearanceだけセットすればUINavigationBar.appearance()のセットと同じような挙動になりました。

standardAppearanceとscrollEdgeAppearanceについて

scrollEdgeAppearanceはスクロール位置が一番上の時に使われるAppearanceでstandardAppearanceはそれ以外に使われるAppearanceです。
そのためscrollEdgeAppearanceだけセットするとスクロール位置が一番上の時だけ設定が反映されます。

それぞれについてのAppleの説明は下のとおりです。
下を見る限りscrollEdgeAppearanceを使わない場合は自動的にstandardAppearanceに見るようです。
そのため今後のアップデートでstandardAppearanceのみセットすれば良くなるかもしれません。

standardAppearance…Describes the appearance attributes for the navigation bar to use when it is displayed with its standard height.
scrollEdgeAppearance…Describes the appearance attributes for the navigation bar to use when an associated UIScrollView has reached the edge abutting the bar (the top edge for the navigation bar). If not set, a modified standardAppearance will be used instead.

その他

Appearanceですが上記以外に下の2つがあります。
こちらはナビゲーションバーが小さい時(画面を横向きにした時)に使われるAppearanceです。

compactAppearance
compactScrollEdgeAppearance

下のようにセットすると横向きの時だけナビゲーションバーが赤くなります。
compactScrollEdgeAppearanceだけはiOS15からなのでavailableで判定しています。

let appearance = UINavigationBarAppearance()
appearance.backgroundColor = UIColor(red: 65/255, green: 105/255, blue: 225/255, alpha: 1)
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
let appearance2 = UINavigationBarAppearance()
appearance2.backgroundColor = UIColor(red: 225/255, green: 105/255, blue: 65/255, alpha: 1)
UINavigationBar.appearance().compactAppearance = appearance2
if #available(iOS 15.0, *) {
   UINavigationBar.appearance().compactScrollEdgeAppearance = appearance2
}

f:id:llcc:20220122114448p:plain

また、UILabelなどスクロールが関係ないものは今まで通り使うことができます。

UILabel.appearance().font = .systemFont(ofSize: 24)

【Kotlin】ContentProviderでGoogle Play services out of dateエラーが出たときの対処

ContentProviderでデータ取得しようとしたら下のようなエラーが出て失敗しました。

Google Play services out of date

調べたところGoogle Play Servicesのアップデートや不足しているSDKを入れる事で対処できるようです。

android studio: LogCatにWarning、Google Play services out of date. Requires 12211000 but found 11580470 | Ninton

Google Play services out of date - Qiita

自分の場合は1つ前のAPIバージョンでエミュレータを作成したら動くようになりました。

f:id:llcc:20220119105116p:plain

カレンダー形式のUIDatePickerの正しいサイズを計算する

UIDatePickerですがiOSから下のようなカレンダー形式が使えるようになりました。

f:id:llcc:20220115194554p:plain

コードは下のとおりです。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let datePicker = UIDatePicker()
        datePicker.preferredDatePickerStyle = .inline
        datePicker.center = view.center
        view.addSubview(datePicker)
    }
}

こちらですが下のようにwidthを広げてしまうと曜日や時間の部分が潰れるという問題があります。
今回はこれを解消するためにsystemLayoutSizeFittingというメソッドを使って正しい高さをセットしてみようと思います。

datePicker.frame.size.width = view.frame.width

f:id:llcc:20220115194908p:plain

systemLayoutSizeFittingとはUIViewのメソッドで、引数に合うSizeを返してくれます。
下のように引数に自身のViewのWidthを入れ、横の優先順位を上げる(defaultHighをセットする)ことで横幅いっぱいの時用のサイズを取得できます。

let targetSize = CGSize(width: view.frame.width, height: 0)
let size = datePicker.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultLow) → (390.0, 397.3333333333333)

あとはdatePickerに取得したsizeをセットすれば曜日や時間部分が潰れなくなります。

datePicker.frame.size = size

f:id:llcc:20220115195555p:plain

datePickerModeを変更した場合、それにあった数字を計算してくれます。

print(datePicker.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultLow)) // → (390.0, 397.3333333333333)
datePicker.datePickerMode = .date
print(datePicker.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultLow)) // → (390.0, 365.0)

ちなみにカレンダー形式UIDatePickerの正しい高さはiOS15とiOS14で微妙に違います。
下がiOS14で高さを計測した時の数字です。

このようにOSのバージョンによって微妙にずれがあるのでできるだけsystemLayoutSizeFittingを使って計算するのが良いかと思います。

print(datePicker.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultLow)) // → (390.0, 405.6666666666667)
datePicker.datePickerMode = .date
print(datePicker.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultLow)) // → (390.0, 368.3333333333333)

ただsystemLayoutSizeFittingは既にDatePickerのサイズが違う状態では違う値になるので注意が必要です。
下のようにviewDidAppearでsystemLayoutSizeFittingを取得し直したらtargetSizeより大きいWidthになってしまいました。
もしviewDidAppear時点でDatePickerのWidthが初期値(320)だと正しい数字が取れるようです。

class ViewController: UIViewController {
    let datePicker = UIDatePicker()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        datePicker.preferredDatePickerStyle = .inline
        let targetSize = CGSize(width: view.frame.width, height: 0)
        datePicker.datePickerMode = .date
        let size = datePicker.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultLow)
        datePicker.frame.size = size
        datePicker.center = view.center
        view.addSubview(datePicker)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        let targetSize = CGSize(width: view.frame.width, height: 0)
        print(datePicker.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .defaultHigh, verticalFittingPriority: .defaultLow)) // → (407.0, 377.0)
    }
}