ObjecTips

Swift & Objective-C で iOS とか macOS とか

Localizable.strings ファイルを plutil で読み込んで変換する

plutil -- property list utility コマンドで Localizable.strings (strings file format) を読み込む事が出来るらしいので試してみる。

元ファイルは以下

/*
 Localizable.strings
 Test
 */

"Yes" = "はい";
"No" = "いいえ";
"Cancel" = "キャンセル";
"Title" = "タイトル";

変換オプションは plutil -convert <fmt> <filepath>
で fmt は xml1, binary1, json の3つ

XML

コマンド plutil -convert xml1 <filepath>

結果

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Cancel</key>
    <string>キャンセル</string>
    <key>No</key>
    <string>いいえ</string>
    <key>Title</key>
    <string>タイトル</string>
    <key>Yes</key>
    <string>はい</string>
</dict>
</plist>
JSON

コマンド plutil -convert json <filepath>

結果

{"Yes":"はい","Cancel":"キャンセル","No":"いいえ","Title":"タイトル"}
Binary Plist

コマンド plutil -convert binary1 <filepath>

結果
中身のテキストはXMLと同じで、フォーマットが binary plist 形式になっている。

JSONフォーマットオプション

JSON変換用のオプションで空白と改行を入れて human-readable にする
plutil -convert json -r <filepath>

結果

{
  "Yes" : "はい",
  "Cancel" : "キャンセル",
  "No" : "いいえ",
  "Title" : "タイトル"
}
拡張子オプション

ファイルを上書きせず元ファイルと同じディレクトリへ指定した拡張子でファイルを書き出す。

plutil -convert json -e json <filepath>
plutil -convert xml1 -e xml <filepath>
plutil -convert binary1 -e plist <filepath>
書き出し先オプション

書き出し先のファイルパスを指定
plutil -convert json -o <output_filepath> <filepath>

lint

ファイルフォーマットが正しいかどうかを調べ、セミコロンが抜けている箇所などを教えてくれる
plutil -lint <filepath>

結果

<filepath>: OK
CFPropertyListCreateFromXMLData(): Old-style plist parser: missing semicolon in dictionary on line 8. Parsing will be abandoned. Break on _CFPropertyListMissingSemicolon to debug.
<filepath>: Unexpected character / at line 1

という事で plutil の中身は CFPropertyListCreateFromXMLData で動いているらしい。


plutil を使った時の難点

  • JSONは仕方ないとして、XMLの時にも書き出し後のファイルのキーの順序がソートされて元の並びと異なってしまう
  • コメント情報が消えてしまう

そもそも strings file format での書き出しが出来ないので読み込みにしか使えないのがイマイチ。

関連

Localizable.strings ファイルを Cocoa で読み書きする

ローカライズに用いる Localizable.strings ファイルは NSDictionary で簡単に読み書きができる。
例えば以下のような Localizable.strings があった時

/* 
  Localizable.strings
  Test
*/

"Yes" = "はい";
"No" = "いいえ";
"Cancel" = "キャンセル";
"Title" = "タイトル";
読み込み
    NSURL *URL; // URL to read
    NSDictionary *dict = [NSDictionary dictionaryWithContentsOfURL:URL];
    NSLog(@"%@", dict);

ログ出力

{
    Cancel = "\U30ad\U30e3\U30f3\U30bb\U30eb";
    No = "\U3044\U3044\U3048";
    Title = "\U30bf\U30a4\U30c8\U30eb";
    Yes = "\U306f\U3044";
}
書き出し
    NSURL *temporaryURL = [[NSURL fileURLWithPath:NSTemporaryDirectory()] URLByAppendingPathComponent:@"temporary.strings"];
    [dict.descriptionInStringsFileFormat writeToURL:temporaryURL atomically:YES encoding:NSUTF8StringEncoding error:nil];

書き出されたファイル

"Yes" = "\U306f\U3044";
"Cancel" = "\U30ad\U30e3\U30f3\U30bb\U30eb";
"No" = "\U3044\U3044\U3048";
"Title" = "\U30bf\U30a4\U30c8\U30eb";

NSDictionary を使った時には以下の難点がある

  • 書き出し後のファイルのキーの順序がソートされて元の並びと異なってしまう
  • コメントが消えてしまう
  • 日本語がユニコードのエスケープシーケンスの形になってしまう

という事で、単純にファイル内のキーと値を読み込む分には良いけど値を加工して書き出す場合にはオススメできない。

関連

SSReadingList で出来る事

リーディングリスト周りのAPIを使って何が出来るのか調査。
Framework は SafariServices.framework でリーディングリスト周りのクラスは SSReadingList のみ。

API は以下

イニシャライザ
+ (nullable SSReadingList *)defaultReadingList;
- (instancetype)init NS_UNAVAILABLE;

ヘッダには alloc init を使わず defaultReadingList を使えと書いてある。
また、リーディングリストへのアクセスが許可されていない場合は nil が返ってくるとの事。
と言ってもリーディングリストへのプライバシーアクセスのAPIは現状では用意されていないようで、設定や機能制限などいろいろいじってみても返り値が nil になる事はなかった。

クラスメソッド
+ (BOOL)supportsURL:(NSURL *)URL;

引数のURLがリーディングリストでサポートされている(追加可能)かどうか。

インスタンスメソッド
- (BOOL)addReadingListItemWithURL:(NSURL *)URL title:(nullable NSString *)title previewText:(nullable NSString *)previewText error:(NSError **)error NS_AVAILABLE_IOS(7_0);

リーディングリストにURLを追加する。
title と previewText で表示するタイトルとサマリーをカスタマイズ出来る。
もしURLが追加されなかった場合はNOが返ってきて引数のエラーに値が設定される。
エラーのドメインSSReadingListErrorDomain でエラーコードは現時点では SSReadingListErrorURLSchemeNotAllowed = 1 の1つのみ。
ヘッダによればサポートするスキームは http:// と https:// のみとの事。

title に @"Title" previewText に @"PreviewText" と引数を渡してリーディングリストに登録すると以下のように設定したタイトル、ウェブページのページタイトル、設定したプレビューテキストが表示される。

f:id:Koze:20160215081228p:plain

title と previewText に nil を渡すと以下のようにタイトルにはURLが入り、ウェブページのページタイトルが表示され、プレビューテキストは無しというような表示になる。

f:id:Koze:20160215081231p:plain

いずれの場合も一度リーディングリストからURLを開くと、設定済みのタイトルとプレビューテキストは破棄されて Safari がよしなに値を設定してくれる。

f:id:Koze:20160215083853p:plain

APIは以上。

まとめ

iOS 9の時点で SSReadingList で出来る事はURLの追加のみ。
リーディングリストの取得編集は不可。残念。
しかしヘッダのコメントの文言から推察するに将来プライバシーアクセス付きでリーディングリストへのアクセスが許可されるかも知れない。(バグレポへの要望次第?)

ちなみに Private API を調べてみたけど SSReadingListWebBookmarksXPCConnection というプライベートクラスを介して動いているらしくSSReadingList から直接リーディングリストの取得編集はできなかった。

watchOS 2.2 API Diffs

前記事の iOS と同じく β3 での変更点に限らず watchOS 2.2 から watchOS 2.3 で今のところ追加予定のものを調査。 (網羅はしてない)

以下ドキュメント

watchOS 2.2 API Diffs / watchOS 2.1 to watchOS 2.2 API Differences https://developer.apple.com/library/prerelease/watchos/releasenotes/General/watchOS22APIDiffs/index.html

CoreText

CoreText 系のクラスが追加!
以前は SNTLayoutType.h と SNTType.h の2つのヘッダがあるのみだったのが、ちゃんと CoreText 系の CTFont CTFrame CTLine CTRun 等々のクラスが追加。
これは大きい。テキスト表示がかなり自由になるのでは?

WatchKit

WKInterfaceActivityRing が追加
自前のアプリでもリングUIが使えるようになるという事らしい

WKInterfaceActivityRing Class Reference

HealthKit

WKInterfaceActivityRing の値設定を行う HKActivitySummary クラスが追加

WatchConnectivity

iOS と同じく複数台の Apple Watch をペアリングした際の挙動に対応させるAPIが追加

MobileCoreServices

kUTTypeLivePhoto が追加
自前アプリでも LivePhoto な画像を扱えるようになる?(ていうか今はできない?)

CoreGraphics

iOS と同じく CGColorConverter が追加

Foundation

iOS と同じく通知の追加

NSUbiquitousUserDefaultsCompletedInitialSyncNotification
NSUbiquitousUserDefaultsDidChangeAccountsNotification
NSUbiquitousUserDefaultsNoCloudAccountNotification
NSUserDefaultsSizeLimitExceededNotification

以上

iOS 9.3 β3 API Diffs

β3 での変更点に限らず iOS 9.2 から iOS 9.3 で今のところ追加予定のものを調査。 (網羅はしてない)

ドキュメントは以下2つ
iOS 9.3 API Diff / iOS 9.2 to iOS 9.3 API Differences https://developer.apple.com/library/prerelease/ios/releasenotes/General/iOS93APIDiffs/index.html

What's New in iOS / iOS 9.3 https://developer.apple.com/library/prerelease/ios/releasenotes/General/WhatsNewIniOS/Articles/iOS9_3.html#//apple_ref/doc/uid/TP40016661-SW1

HealthKitUI HealthKit

まず目立つところで HealthKitUI Framework が追加。
クラスは HKActivityRingView のみで関連クラスは HealthKit の方に追加。
このビューは Apple Watch でアクティビティの状況を表示するあのリングのUIで、Move, Exercise, Stand の役割と色は変更できないけど追加された HKActivitySummary クラスを使って表示する値は変更できそう。

あのリングのUIは以下
HKActivityRingView Class Reference

ちなみに watchOS には WKInterfaceObject のサブクラスとして WKInterfaceActivityRing が追加され、tvOS には iOS と同じく HealthKitUI Framework ごと追加された。

iAd

サービス撤退が表明された iAd の Framework
変更点が気になるところだけど AdBannerView.h に ADErrorAssetLoadFailure が追加されたのみだった。

MapKit

MKLocalSearchCompleter クラスが追加
ローカルサーチ自体は iOS 6 で 追加されたMKLocalSearch クラスでもできて MKLocalSearch では検索ワードの設定、検索の開始とキャンセル、検索中かどうかのフラグ、検索結果などが取得できる。
ドキュメントがまだないのでヘッダを見てみたところ MKLocalSearchCompleter ではエラーハンドリングや簡単なフィルタリングなども提供されているっぽい。

CloudKit

CKOperationlongLived というプロパティとその関連のAPIが追加された。
Long-Lived については以下

CKOperation Class Reference

アプリを閉じた後も動く!との事。
以前は usesBackgroundSession というプロパティがあったけど iOS 9 で deprecated になっていた。
ドキュメントによれば Long-Lived なオペレーションを開始するとデーモンが動いて、オペレーションが終了するとアプリに completionHandler で教えてくれるという事らしい。
オペレーションは24時間稼働可能。

CoreGraphics

CGColorConverter が追加された。
追加された関数を見た感じカラースペースの変換をしてくれるらしいけど作った CGColorConverterRef をどう使うのかがいまいち分からない、、

Foundation

NSUserDefaults に通知が追加。

NSUbiquitousUserDefaultsCompletedInitialSyncNotification
NSUbiquitousUserDefaultsDidChangeAccountsNotification
NSUbiquitousUserDefaultsNoCloudAccountNotification
NSUserDefaultsSizeLimitExceededNotification
  • iCloud の UserDefaults との初期同期、ダウンロードが完了。iCloudへの初回接続とアカウントを切り替えた際に呼ばれる。
  • iCloud のアカウントが変更された際に呼ばれる。
  • iCloud のアカウントがない。
  • iCloud の UserDefaults に入れられるデータ量を超えた時に呼ばれる。tvOSを除いてはローカルの UserDefaults に容量制限は無い。制限はiCloudアカウントに依存する。

この4つの通知
ていうかこれ NSUbiquitousKeyValueStore.h じゃなくて NSUserDefaults.h に定義されてるんだ。

MediaPlayer

+[MPMediaLibrary authorizationStatus]
+[MPMediaLibrary requestAuthorization:]

プライバシーアクセスのAPIが追加された。

他にも StoreKit に追加されたAPIと組み合わせて Apple Music の曲をユーザのライブラリに追加して再生する事が出来るらしいAPIが追加された。

StoreKit

前述の MediaPlayer Framework と同じく Apple Music 周りのAPIがいろいろと追加。

+[SKCloudServiceController authorizationStatus]
+[SKCloudServiceController requestAuthorization:]

プライバシーアクセスのAPIも追加。

UIKit

UIUserInterfaceIdiomCarPlay が追加。
一般のデベロッパには関係無さそう

WatchConnectivity

iOS 9.3 で1台の iPhone複数台の Apple Watch をペアリング出来るようになるので、新しく追加されたAPIを活用して対応しろという事らしい。

Apps that do not adopt these new interfaces may be terminated unexpectedly when a switch occurs.

対応してないと Apple Watch を切り替えた時に予期せず終了(クラッシュ?)するぞ的な文言も。

以上

UITextView で「リッチテキストとしてペースト」pasteAsRichText を実装する その2

前回

前回の結果を踏まえて copy: メソッドもオーバーライドして挙動をカスタマイズする事にする。
簡単なのは copy: メソッドの super の挙動を呼び出してペーストボードの中身を作ってから自前で追加したいものを加える方法。

実装

- (void)copy:(id)sender
{
    [super copy:sender];

    // RTFDを作成
    NSAttributedString *aString = [self.textStorage attributedSubstringFromRange:self.selectedRange];
    NSData *data = [aString dataFromRange:NSMakeRange(0, aString.length)
                       documentAttributes:@{NSDocumentTypeDocumentAttribute: NSRTFDTextDocumentType}
                                    error:nil];
    // アイテムを追加
    UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
    [pasteboard addItems:@[@{(NSString *)kUTTypeRTFD: data}]];
}

ペースト側の valueForPasteboardType: メソッドでは2つ目のアイテムを取得する事が出来ないのでペースト時のデータ取得の実装も少し修正する

- (void)pasteAsRichText:(id)sender
{
    UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
    NSData *data = [pasteboard dataForPasteboardType:(NSString *)kUTTypeRTFD inItemSet:nil].lastObject;
    NSAttributedString *aString = [[NSAttributedString alloc] initWithData:data
                                                                   options:nil
                                                        documentAttributes:nil
                                                                     error:nil];
    NSRange selectedRange = self.selectedRange;
    [self.textStorage replaceCharactersInRange:selectedRange withAttributedString:aString];
    // ペースト後にキャレット位置を移動
    self.selectedRange = NSMakeRange(selectedRange.location + aString.length, 0);
}

同様の理由で canPerformAction:withSender: メソッドも少し修正する。

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
    if (action == @selector(pasteAsRichText:)) {
        UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
        return [pasteboard containsPasteboardTypes:@[(NSString *)kUTTypeRTFD] inItemSet:nil];
    }
    return [super canPerformAction:action withSender:sender];
}

この実装でペーストしてみる

f:id:Koze:20160208094314p:plain

コピー元のアトリビュートがそのまま反映された。pasteAsRichText: に成功。

まとめ

問題点

若干の問題点として
[super copy:sender]; をした後に addItems: をしているため、UIPasteboardchangeCount が2回インクリメントされてしまうというのがある。
これが問題にならなければ上記の実装で良いかも知れない。

もしこの現象を避けるのであればペーストボードの中身を全て自分で作ってから一気にアイテムを設定してやればいい。
その際 kUTTypeWebArchive Apple Web Archive pasteboard type の中身のデータを自作するのがネックになるので、このペーストボードのタイプを落とせない場合はペーストボードの中身の自作はあまりお勧めできない。

別のアプローチとして [super copy:sender]; は呼びつつ、自前で追加するRFTDのアイテムは generalPasteboard を使わないで pasteboardWithName:create: メソッドで作った自前のペーストボードを使うという方法がある。
この場合は独自に作ったペーストボードの名前を知っているアプリ(アプリ自体と他の自作アプリ)間ではコピーペーストでのRFTDの受け渡しができるけど、他のアプリとの連携ができなくなってなってしまう。
状況に合わせて判断という事になると思う。

UITextView で「リッチテキストとしてペースト」pasteAsRichText を実装する

OS Xでの挙動

OS XNSTextView には

- (void)paste:(id)sender;
- (void)pasteAsPlainText:(id)sender;
- (void)pasteAsRichText:(id)sender;

これらのメソッドがあって、システムのペーストの挙動をそのまま使う事もできるしプレーンテキストやリッチテキストでペーストをする事もできる。
編集メニューの「ペースト」使った場合は paste: メソッドが呼ばれてペーストボード上にある NSAttributedString がそのままペーストされる。動きとしては pasteAtRichText: メソッドと同じになる。
編集メニューの「ペーストしてスタイルを合わせる」Paste and Match Style を使った場合は pasteAsPlainText: メソッドが呼ばれ、テキストビューのキャレットの位置のアトリビュートが反映された文字列がペーストされる。 *1

iOSでの挙動

iOSUITextView では標準では

- (void)paste:(id)sender;

が使える。
このメソッドにペースト処理を任せた場合、例えば以下のように黒字と赤字の部分をまとめてコピーしてテキストの末尾にペーストすると赤文字でペーストされる。

ここでコピー
f:id:Koze:20160208091604p:plain

ここでペースト
f:id:Koze:20160208091606p:plain

結果
f:id:Koze:20160208091607p:plain

OS Xでいう「ペーストしてスタイルを合わせる」Paste and Match Style と同じ挙動になるので、iOSOS Xとではデフォルトのペーストの挙動が違うという事になる。
そこでiOSの方に足りていない、コピーしたリッチテキスト (NSAttributedString) をそのままペーストするための pasteAsRichText: メソッドを実装する。

実装

まずポップアップメニュー表示のメニュー一覧に独自のメソッドが表示されるようメニューの追加を任意のタイミングで行っておく。

    UIMenuItem *menuItem = [[UIMenuItem alloc] initWithTitle:@"Paste As Rich Text" action:@selector(pasteAsRichText:)];
    [UIMenuController sharedMenuController].menuItems = @[menuItem];

そして設定した pasteAsRichText: メソッドへの対応を UITextView のサブクラスで実装する。

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
    if (action == @selector(pasteAsRichText:)) {
        UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
        return [pasteboard containsPasteboardTypes:@[(NSString *)kUTTypeFlatRTFD]];
    }
    return [super canPerformAction:action withSender:sender];
}

コピー操作後のペーストボードの pasteboardTypes を確認すると

(
    "com.apple.flat-rtfd",
    "public.utf8-plain-text",
    "Apple Web Archive pasteboard type"
)

の3つが入っていたので com.apple.flat-rtfd のタイプを示す kUTTypeFlatRTFD を使用している。
この定義は MobileCoreServices.framework のヘッダの載っていて kUTTypeFlatRTFD Flattened RTFD (pasteboard format) と書かれているので、ペーストボード用のRTFDフォーマットらしいという事が分かる。

canPerformAction:sender: メソッドの実装内容としては、このタイプのデータがペーストボードに入っていれば "Paste As Rich Text"のメニューが有効になる(表示される)ようにしている。

そして実際にペーストを行うメソッド

- (void)pasteAsRichText:(id)sender
{
    UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
    NSData *data = [pasteboard dataForPasteboardType:(NSString *)kUTTypeFlatRTFD];
    NSAttributedString *aString = [[NSAttributedString alloc] initWithData:data
                                                                   options:nil
                                                        documentAttributes:nil
                                                                     error:nil];
    NSRange selectedRange = self.selectedRange;
    [self.textStorage replaceCharactersInRange:selectedRange withAttributedString:aString];
    // ペースト後にキャレット位置を移動
    self.selectedRange = NSMakeRange(selectedRange.location + aString.length, 0);
}

ペーストボードから kUTTypeFlatRTFD で取り出せるデータは NSData なので、そこから NSAttributedString を作って UITextView にペーストする。
ペーストの際には単純に追加するのではなく、現在の選択範囲とペースト後の選択範囲(キャレットの位置)を考慮する。

ここまでの実装で "Paste As Rich Text" を実行

f:id:Koze:20160208092432p:plain

これはいけない。コピー元のテキストのアトリビュートが正しく反映されていない。

NSData から作成した NSAttributedString の中身を確認すると

pariatur. Excepteur{
    NSFont = "<UICTFont: 0x7f9072c55d00> font-family: \"Helvetica\"; font-weight: normal; font-style: normal; font-size: 12.00pt";
    NSParagraphStyle = "Alignment 0, LineSpacing 0, ParagraphSpacing 0, ParagraphSpacingBefore 0, HeadIndent 0, TailIndent 0, FirstLineHeadIndent 0, LineHeight 0/0, LineHeightMultiple 0, LineBreakMode 0, Tabs (\n    28L,\n    56L,\n    84L,\n    112L,\n    140L,\n    168L,\n    196L,\n    224L,\n    252L,\n    280L,\n    308L,\n    336L\n), DefaultTabInterval 36, Blocks (null), Lists (\n), BaseWritingDirection -1, HyphenationFactor 0, TighteningForTruncation NO, HeaderLevel 0";
}

となっていてコピー元のアトリビュートと変わっていた。
おそらく kUTTypeFlatRTFD やデフォルトの copy: 操作の挙動の仕様だろうという事で copy: から挙動をカスタマイズする必要がありそう。

という事で次回に続く。

*1:データとしてはアトリビュートが無いテキストがペースト追加されているけど、見た目上の結果としてアトリビュートが無いテキストが追加されるわけではない事に若干注意