ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
TextKit 2について
TextKit 2について: Appleの次世代テキストエンジンであり、設計の見直しによって正確性、安全性、性能が向上しています。TextKit 2を使用することで、世界各国のユーザにより優れたテキスト体験を提供し、テキストコンテンツとビジュアルコンテンツを組み合わせることでレイアウトを多様化して、スムーズなスクロール操作を実現する方法を紹介します。最新のAPIを紹介して、いくつかの実用的な事例を詳しく検証して、Appを最新化するための指針を提示します。
リソース
関連ビデオ
WWDC22
WWDC21
-
ダウンロード
Donna: こんにちは TextKitエンジニアのDonna Tomです 同僚のChris Willmoreも のちほど登場します これからTextKit 2を紹介します Appleの 次世代テキストエンジンです
TextKit 2を理解する上で まずオリジナルのTextKitを 振り返りましょう ここではTextKit 1と呼びます TextKit 1は テキストエンジンです Appleの 全プラットフォームに テキストのレイアウトや 表示を行います UIKitとAppKitの テキストコントロールは ストレージ管理と テキストのレイアウト制御に TextKit 1を使用します
TextKit 1はOpenStepの システム上で初登場しました 20年以上前のことです 以来年月を重ね 私たちと共に進化しました macOS 10.0から iOS 7 そしてmacOS 11やiOS 14まで
素晴らしいのは TextKit 1が今でも Appleの全デバイスの基本的機能の 土台であることです テクノロジーデザインと エンジニアリングの 原則は 年月を重ね 大きく変化しました TextKit 1はオリジナルの 原則に基づいているため 高水準のパフォーマンスを維持しつつ 最新技術に適合するAPIを 提供することが 年月の経過とともに難しくなりました
そこで TextKit 2を開発しました TextKit 2は 前進的なデザインの原則に に基づいた Appleの 次世代テキストエンジンです 実はあなたも すでにそのMacで TextKit 2を使っています Big Surでは OS内の多くのテキスト要素が更新され TextKit 2がバックグラウンドで 利用されています TextKit 2は実は macOS 11から提供されていたのです これを可能にするアーキテクチャを 簡潔に振り返りましょう
TextKit 2は TextKit 1と共存し
先代と同様 TextKit 2はFoundation QuartzそしてCore Textの 上位に構築されています UIKitとAppKitのテキスト制御は TextKit 2の上部に構築されています またTextKit 2は 先代のMVCデザインを ゆるい形で継承しています ”View"には UIKitと AppKitフレームワークの オブジェクトをそのまま配置 一方おなじみの NSTextStorageと NSLayoutManagerの 最新バージョンも搭載しています
これらの 最新バージョンに加えて 新しいクラスとプロトコルを多数 ModelとControlのレイヤーに 追加しています かなり多数ありますが ご安心を この新しいコンポーネントは とてもシンプルで 相互にパワフルに作用します
テキストの表現が より簡単になり システムの動作について 心配する必要もありません
システムの全体像を 確認したところで 詳細を見ていきましょう
TextKit 2のコアデザイン原則を 見てみましょう まず これらの原則がAppの ストレージやレイアウト テキスト表示の カスタマイズにいかに変化を及ぼすか
その後 Chrisが TextKit 2のサンプルAppで レシピ本の事例を 紹介します
このAppは新しい TextKit 2クラスで CALayersでのテキストの レイアウトや表示に用います ではこれから デザインの原則の実践方法を 学びましょう
最後に TextKit 2に向けた Appの最新化における 重要な技術的な詳細についてお話します
ではデザインの原則から始めましょう
TextKit 2のハイレベルな コアデザインの原則は 正確さと安全性 そしてパフォーマンスです バランスのあるアプローチを取りました これら3つの原則は それぞれ重要で 優先順はありません
これらのハイレベルな デザインの原則が システム内の特定の デザイン変更を規定します
正確さにおいて TextKit 2は グリフ処理を抽象化し
安全性においては TextKit 2は 値セマンティクスをより重視し
パフォーマンスでは ビューポートベースのレイアウトと レンダリングを採用しました
まず正確性については グリフ処理を抽象化しました 各言語のテキストで 一貫性のある体験を担保するためです Appleのデバイスは 世界中で使われているため 各言語とスクリプトにおける レイアウトやレンダリング テキストとのインタラクションが 正確に提供されることがとても重要です
誰もが自分のデバイスでテキストを読み 操作できる環境を提供することです TextKit 1 APIの いくつかのデザインでは 言語によっては作業が困難で グローバルな正確性を担保できない 場合があります
原因を理解するためには まずグリフとは何かを 理解する必要があります グリフは様々なタイプの文字の 変数を 視覚的に表します
多くの西洋言語では 1つのグリフは通常 1つの文字を示します しかし常にそうであるとは限りません
1つの文字に複数のグリフがある場合や その逆もあります つまり1つのグリフで 複数の文字を 表すパターンです
このように 複数の文字を表す 単一グリフは 合字と呼ばれます
西欧言語には 合字は多くありません そして文章の読みやすさにも 影響は与えません 合字なしでも 十分に読むことができます
しかし全ての言語が そういうわけではありません アラビア文字や デーヴァナーガリーなどは 多くの合字を使用し テキストの読みやすさに 影響を与えます アラビア文字表記を 見てみましょう
これはウルドゥー語で ”瞬間"を意味します ここでこの2つの レンダリングを 比較してみましょう
右側が 合字で表示したもの 左側が個別の文字で 表示したものです 差は歴然で
ネイティブの読み手にとって 左側は判読不可でしょう
TextKit 1の多くのAPIでは グリフのインデックス または範囲が重要です 例えば テキストの境界矩形を確保するには テキストのグリフ範囲の確認が必要です
テキストが西洋言語の場合 グリフ範囲の判定は比較的簡単です
英語で見てみると テキスト冒頭の4文字に係る グリフ範囲はすぐにわかりますね
次にカンナダ語を見て見ましょう インドで 100万人に話されている言語です
多くの合字が必要なだけでなく 様々な形の グリフの順番の入れ替えや結合があります
カンナダ語で"10月"を 意味するこの言葉では 4つ目の文字インデックスの 分割母音でグリフが2つに分割し
1と2の文字の合字が適用される前に 左側で この二文字を表す グリフの順番が変わります
インデックス3の文字を表すグリフは 結合型に代替されます
最終的な言葉では 分離した母音の グリフの1つの下に配置されます さて 今お話しした内容が 理解できなかったとしても 全く問題ありません
このフレームワークが これらの処理をしてくれるので デベロッパはApp構築に集中できます
つまり このようなテキストの 最初の4文字の グリフ範囲を 把握するのは不可能です この4文字を表す グリフ範囲は 存在しません
多くのTextKit 1 APIでは グリフ範囲が要求されるため これらのAPIを使用すると 複雑な文字群に対して レイアウトやレンダリングを 崩してしまう可能性があります このため TextKit 2ではグリフ処理を 抽象化します TextKit 2はCore Textで 全テキストをレンダリングし 複雑な文字でも 正しいレンダリングが 自動的に得られます
TextKit 2ではグリフの 管理は一才必要がありません その代わり テキストのレイアウトや インタラクションには 高レベルのオブジェクトを 使用します
NSTextSelectionは この高レベルオブジェクトの1つです
テキストの選択に必要な 粒度や 類似性 および選択を構成する テキスト範囲の分割といった すべてのコンテクスト情報を含みます
こうしたNSTextSelection のプロパティは 読み出し専用です 選択オブジェクトのインスタンスは 変更できません ただし NSTextSelectionNavigation のインスタンスを使い 選択の結果を表す NSTextSelectionの 新しいインスタンスを受けて テキスト選択のアクションを実行します
ナビゲーションオブジェクトに 照会して 画面のポイント上の タップやマウスダウンによる 選択の結果を得たり 前後のナビゲーションで得られる 新しい選択を 入手することもできます これで 言葉1つ分で 選択を拡大したり 右から左の言語での 双方向のテキストに対応して 正しい結果を得たりするなど さまざまな作業を容易にできます
これらの新しい 選択APIについて 興味深い話を紹介します この方法には NSTextLocationを使用します これもTextKit 2に搭載の 新しいオブジェクトです
ではNSTextLocationと NSTextRangeを紹介します
この2つはUIKitのUITextPositionと UITextRangeクラスによく似ています ただしどちらもサブクラス化 する必要はありません 通常 TextKit 2では デフォルトの場所と 範囲オブジェクトを使用します
整数の代わりに オブジェクトを利用するので より豊かなドキュメントモデルが可能です 互いとの相対的な場所に基づき 範囲が決まるからです HTMLドキュメント オブジェクトモデルは この良い例です ネスト化された要素を持つため 場所は ドキュメント内の絶対的ポジションと 目に見えるテキストの ポジションの 両方を表す必要があります 単一の数値化した インデックスではこれは 表現できません
ここまで正確性について お話ししました ここからは安全性についてです
TextKit 2は 値セマンティクスに 重点を置いてデザインしました SwiftやSwiftUIの テクノロジーの目的との 整合性を改善するためです
”値セマンティクス”というのは 値のタイプのことではありません 私たちはNSLayoutManager を構造に含めませんでした
値のタイプはデータの変化を防止する データのユニークコピーを維持します 意図せぬ共有を防いだり 関連する副作用をなくし コードを安定かつ安全にするものです でも値のタイプは この目的を達成する 唯一の方法ではありません
不変クラスは 初期化後に変更不可の プロパティを保持するので データの改変を防止します
これらのクラスは値のタイプと 同じ挙動をするため 値セマンティクスとして参照します あるオブジェクトの データを変更したい場合 オリジナルを置き換える 新しいインスタンスの作成が必要です TextKit 2に含まれる 多くのクラスは このようにデザインされています
このデザイン変更による 恩恵を理解するため TextKit 1のデザインについて 記憶を新たにしましょう ストレージから スクリーンへの テキストのフローは 以前はこのような仕組みでした
テキストストレージへの更新が レイアウトマネージャに通知され レイアウトマネージャは これを受けグリフを生成し グリフをビューへ直接配置し 描きます
このようにビューへ直接グリフを描く アプローチでは カスタム描画のスペース生成で テキストを分離する箇所が わかりにくいという問題がありました
これを理解するために この画像を見て下さい サンプル用Appです 私がレシピにコメントを残しました
コメントがレシピの下の 目立つバブル型の 青い背景に 白字のテキストで表示されます
コメントを正しい場所に 配置して 残りのテキストと 差別化するには どうすれば良いでしょうか
このようなアプローチを 考えられるかもしれません レシピテキストを 意味あるまとまりごと または要素ごとに分け それぞれのコメントを それぞれの要素に収め 関連するレシピの後に 配置し コメントの表示方法 に関する指示を提供する といった方法です TextKit 1では 実際の運用は大分異なります 心配すべき事項が数々あります グリフインデックスを探したり グリフが書記素クラスタの 中にないことを確認したり もし中にあれば グリフインデックスを修正し 行間を変更したり 行フラグメントのジオメトリを カスタマイズするなど
これらの詳細は 本来の目的にあまり関係ありません そこでTextKit 2では これらの期待を現実にし こうしたアプローチを可能にするため システム内のテキストの フローを変更しました
TextKit 2のフローを紹介します
テキストストレージへの アップデートは コンテンツマネージャという 新オブジェクトを経由します
コンテンツマネージャは テキストを要素に分解し それらを追跡します レイアウトの際には テキストレイアウト マネージャが コンテンツマネージャに 要素を照会します
テキストレイアウトマネージャは テキストコンテナへ要素を配置し そしてレイアウトと配置の情報を含む レイアウトフラグメントを生成します
表示の段階では レイアウトフラグメントが ViewportLayoutControllerへ送られ ビューかレイヤーかの レンダリングサーフェスに従い フラグメントの レイアウトと配置を調整します
ご覧のように このプロセスには多くの 新しいオブジェクトが関わります ここで値セマンティクスの出番です
正しいポイントで システムにアクセスし テキストのレイアウトと 表示をコントロールし 値セマンティクスを利用する オブジェクトから 必要な情報を取得します
変更する場合 まず希望する変更を 値オブジェクトの 新しいインスタンスで作成します そしてそれを システムに戻します システムは その置換オブジェクトの 値を利用し レイアウトと ディスプレイに反映します
では 新しいオブジェクトと これらオブジェクトの受信や 置き換えが発生する システム内の異なるポイントを把握しましょう まずは ストレージオブジェクトの
NSTextElementです 要素はドキュメントを 構成するものです それぞれの要素は コンテンツの一部分を示し ドキュメント内の場所を表す 範囲を含みます 要素は値セマンティクスを含みます 範囲を含むこれらのプロパティは 不変で 要素作成以降は 変更不可能です
文字の連続でなく 要素の連続として ドキュメントを形作る場合 よりパワフルな操作が可能になります 与えられた要素がどのような コンテンツに対応するのか すぐ判別が可能になります テキスト 段落 添付ファイル あるいはその他のカスタムタイプか これらのタイプによって 要素の配置を決定できます
次に NSTextContentManagerです
コンテンツマネージャは テキストコンテンツから 要素を生成し ドキュメント全体の中で 各要素の範囲を 確認します またバッキングストアとも連携し バッキングストア内の コンテンツに変更があると 更新された範囲で 新しい要素を生成します
コンテンツマネージャは バッキングストアを まとめたものであると みなせます
コンテンツマネージャは 生データを要素へと 翻訳するインターフェイスを 提供します
NSTextContentManagerと NSTextElementは 共に抽象型です カスタムの ドキュメントモデルや バッキングストア使用時に これらをサブクラス化することが できます ヘッダーと文書化が 良い例です ですが通常は TextKit 2のデフォルトを 利用できます
次にNSTextContentStorageと NSTextParagraphです これらはデフォルトのコンテンツ マネージャと要素タイプです NSTextContentStorageは コンテンツマネージャで NSTextStorageを バッキングストアとして利用します
テキストストレージの コンテンツ分割を担い 段落の要素 つまりNSTextParagraphの インスタンスに分けることができます NSTextContentStorageはまた テキストストレージ内の テキストが変更した時 更新された段落の要素を生成します ここが重要なポイントですが
下層のテキストストレージに 変更を加える時 performEditingTransaction メソッドで更新内容を ラップします これで あなたが行った変更について TextKit 2の他の部分が通知されます
コンテンツストレージのデリゲートで NSTextContentManagerサブクラスを フル実装する必要がなく 様々なことが実現できます
後ほどコンテンツデリゲートで テキストストレージを変更せず コメントのフォントや色を 変更する方法や コメントをすべて非表示にする方法を Chrisが説明します この後もお楽しみに
では TextKit 2が テキストコンテンツから 要素を生成する流れが 理解できたので 新しいアプローチの 最初の2つのステップを カバーできます
コンテンツストレージは自動的に テキストを段落ごとに分解し 新しいコメントに 新しい段落を作成します
次に 最後の2つのステップについて お話します コメントの位置と表示です このフローに戻りましょう コメント要素に関する レイアウト情報を入手します このタスクのための新しい レイアウトオブジェクトがあります では見てみましょう
NSTextLayoutManagerです このマネージャはテキストレイアウト の処理を管理します NSTextLayoutManagerは TextKit 1の NSLayoutManagerに似ていますが 大きな違いが1つあります NSTextLayoutManagerは グリフを扱いません
その代わり NSTextLayoutManagerは テキスト要素を テキストコンテナに並べて これら要素のレイアウト フラグメントを生成します
あなたもテキスト要素の レイアウト情報の取得に フラグメントを使いますね ではレイアウトフラグメント について学びましょう
NSTextLayoutFragmentです レイアウトフラグメントは 1つ以上のテキスト要素の レイアウト情報を含みます 要素同様に 値セマンティクスを使用し プロパティは不変です
テキストレイアウトマネージャは 各コメント要素に対して レイアウトフラグメントを作成し レイアウトフラグメントの情報を基に コメントを配置し 表示します
レイアウトフラグメントは レイアウト情報を 3つのプロパティを通して伝達します textLineFragmentsの配列 layoutFragmentFrame そしてrenderingSurfaceBoundsです
レイアウトを変更または カスタマイズする場合 各プロパティから得る情報を 理解する必要があります それをこれから見ていきます
最初のプロパティは NSTextLineFragmentです ラインフラグメントは レイアウトフラグメント内の テキストの各行の測定情報を 含みます
これらは 特定の行の幾何学的情報の取得や レイアウトフラグメント内の 行数のカウントに役立ちます
第2のプロパティ レイアウトフラグメントフレームは レイアウトフラグメントのテキストが どのようにテキストコンテナ内での 配置されるか規定します TextKit 2では テキストレイアウトは基本的に コンテナ内のレイアウトフラグメント フレームをスタックします これらのフレームをタイルとして 捉えてみましょう システムはテキストコンテナのエリアを タイルに分割します 各レイアウトフラグメントは 1つのタイルと捉えられます
この表のように 空の行は それぞれレイアウトフラグメント フレームを有します 一般的にレイアウトフラグメント フレームは UIの他のビューを フラグメントコンテンツの近くに 配置したり テキストコンテンツ全体の 高さを計測するのに役立ちます
このフレームは テキストの配置に 必要なスペースを 正確に表していません その情報は 第3のプロパティから得られます レンダリングの表面の境界は テキストを描写する エリアを示しています この長方形を使って ビューの座標空間での テキストのサイズを 取得します これはレイアウトフラグメント フレームとは異ります というのもテキストは フラグメントフレームの 端を超える場合があるからです これは発音区別符号や イタリックフォントの 下部が長い場合に 発生します ”J"の左端を見てください フレームから ほんのわずかですが 突き出ています もっと極端な例では
”Zapfino”などのフォントでは タイポグラフィの枠を 大きくはみ出る グリフを持つ場合があります レンダリングの表面の境界が レイアウトフラグメントフレームより かなり大きくなる場合があります さて
レイアウトフラグメントが提供する レイアウト情報について 理解できたところで 少し戻ってみましょう この情報の使った テキストレイアウトの カスタマイズについて話しましょう
レイアウトフラグメントは不変なので フラグメント上でレイアウト情報を 直接変更できません
このフローに戻ります レイアウトプロセスを使い 変更したい情報で NSTextLayoutFragmentの 新しいインスタンスを作成します
NSTextLayoutManagerの デリゲートメソッドを使い レイアウトプロセスを組みます レイアウトマネージャが 要素から レイアウト フラグメントを生成する時 このメソッドが呼び出されます ここで要素について 独自のレイアウトフラグメントを 作成することができます
これでコメントに関する 問題へのアプローチで 最後の2つのステップが解決します NSTextLayoutFragmentの サブクラスを使って コメントのレイアウトフラグメントの 配置とカスタム描写に対処し レイアウトマネージャのデリゲートで カスタムフラグメントの インスタンスを提供します
ビデオの後半では ChrisがサンプルAppのデモで この方法を紹介します
以上が安全性についてです 次にパフォーマンスです
パフォーマンスは どのテキストエンジンでも 最も難しい課題の1つです TextKit 2は非常に高速で 幅広いシナリオに対応しています それぞれ数行ずつのラベルを 即時にレンダリングしたり 数百メガバイトのドキュメントを スムーズにスクロールできるようにしたり これらのシナリオで 非常に大きなドキュメントを 様々なレートで 扱う場合 不連続のテキストレイアウトが 優良なパフォーマンスには不可欠です
ここで 連続と不連続のレイアウトの違いを 振り返りましょう
この図を見てください 黄色い四角は 画面上で表示された コンテンツを示します
連続レイアウトは ドキュメントの 最初から最後まで 順番に進みます
ドキュメントの中央に スクロールする場合 連続レイアウトは スクロールしたポイントよりも 前の全てのテキストに対しても レイアウトを実行します
つまり画面外のテキストも含み 冒頭から スクロールされたテキスト全てです もしテキストが大量の場合 パフォーマンスが遅くなり アニメーションがあれば スクロール時に問題が発生したり 最悪の場合フリーズする 可能性もあります
一方で 不連続レイアウトでは ドキュメント内のどの箇所でも テキストの一部をレイアウトでき それ以前のテキストの レイアウトは不要です
ドキュメントの中央まで スクロールした場合 現在画面に表示される部分が すぐにレイアウトされます
表示部分のテキストだけに レイアウトが実施されるので パフォーマンスが向上します オーバースクロールの域でも スムーズなスクロールが実現します TextKit 2でのレイアウトは 常に不連続です
一方TextKit 1では 不連続レイアウトはオプトインで
NSLayoutManagerの ブール型プロパティで 有効化されます このAPIはシンプルですが それゆえに レイアウト情報のリクエスト時に レイアウトの状態を表現できません
不連続のレイアウトは ドキュメントの別の部分が レイアウトされた時点で 変わり得る予測に基づいています TextKit 1では 非連続レイアウトの オンオフしか設定できません ドキュメントがレイアウトされる 部分について一切管理できず レイアウトが終了したか または正しい値に 更新されたかなどを 確認できません
TextKit 2のAPIは より豊かで表現の幅も広がります
TextKit 2では 表示されるコンテンツ領域内の 要素のレイアウト情報を常時提供し この表示領域に レイアウトアップデートがあれば 通知します
このエリアをビューポートと呼びます 自分でビューポートを管理し 調整や移動ができます ビューポートレイアウトの 前や途中 そして後に コールバックを受け取れます
最適なパフォーマンスのために あなたのコードは ビューポート領域内の レイアウト情報に集中すると 良いでしょう ビューポート外部の要素については レイアウト情報のリクエスト を避けましょう
ビューポート外部の要素のビューポート レイアウト情報は テキスト範囲のレイアウトが これらの要素に マッチしていることを 明示的に確認しない限り 正確ではない可能性があります ドキュメントのボリュームが 大きければコストも高くなります
先程のフローに戻ります ビューポートの管理に利用できる もう1つの新しい コントローラクラス
NSTextViewportLayoutController があります これがビューポートのレイアウト情報の 正確な情報のソースです ビューポート内の要素の レイアウトフラグメントを 取得するため テキストレイアウト マネージャに照会します ビューポートレイアウト コントローラへは テキストレイアウトマネージャの プロパティからアクセスできます
ビューポートレイアウトコントローラ について見たところで ビューポートレイアウトプロセス について話します
ビューポートレイアウトコントローラは ビューポートレイアウトプロセスの間 デリゲートで3つの重要なメソッドを 呼び出します TextViewportLayoutController WillLayout textViewportController configureRenderingSurface FortextLayoutFragment textViewportLayoutController DidLayoutです
まず ビューポートに要素を配置する前に レイアウトコントローラが willLayoutメソッドを呼び出します ここで ビューまたはレイヤーの コンテンツをクリアにするなど レイアウトの準備のための 設定を行います
次に ビューポートで表示される 全レイアウトフラグメントに対し レイアウトコントローラがconfigure RenderingSurfaceを呼び出します ここで各フラグメントビュー またはレイヤーの ジオメトリをアップデートします
最後に ビューポートで表示される 全レイアウトフラグメントの レイアウト完了後に ビューポートレイアウトコントローラが didLayout methodを 呼び出します
ビューポートレイアウト完了後に 変更の必要があれば ここで アップデートを行います 例えば 最後の要素を画面で フル表示させるため ビューポートを調整したい 場合などに該当します 以上パフォーマンスについて お話ししました ここからChrisにバトンタッチして TextKit 2の使用方法を紹介します ありがとうDonna これから私たちが作成した サンプルAppを例に TextKit 2を使った App内のテキストレイアウトや 操作の方法を紹介します ここで紹介するサンプルコードは ダウンロードできるので 入手して実践してみてください このコラボAppで 今日のランチに何を作るか 決めるのに レシピ本をチェックします 期待通りレシピをスクロールできます この時バックグラウンドでは 特別なことが起きています ビューポートで目視可能なパラグラフのみ ここに表示されています 全パラグラフが画面全体に レンダリングされる代わりに 各パラグラフがそれぞれのレイヤーに レンダリングされます
ツールバーの「Show Bounds」 ボタンをクリックすると 色付きの長方形が表示されます オレンジ色の長方形は 各レイヤーの境界を示します テキストを 各レイヤーに個別に描画すると レシピにコメントを書く 機能を実装できます エッグサンドが美味しそうなので このパラグラフをダブルクリックし 「すごく美味しそう」とタイプしてみます Enterキーで コメントが挿入されました
これで今 このドキュメント内に 新しいパラグラフを挿入した ことになります バブルの背景は NSTextLayoutFragmentの カスタムサブクラスで 描かれたもので BubbleLayoutFragment と呼びます 後で詳しくお話しします
ドキュメントに コメントを挿入すると 挿入箇所以降のパラグラフが コメントに必要な スペースを確保できるよう 移動します 最初見て わからなかった場合は ツールバーの この亀のボタンを クリックすると スローモードに変わります
別のコメントを 追加してみましょう 「今日ランチでも一緒に」 そしてEnterを押すと ドキュメントの下に挿入され それ以降の 全てのバラグラフが ゆっくりアニメで移動します もし 全てのコメントを 非表示にするなら ”Toggle Comments"ボタンを クリックします ツールバーにあります これは ベースドキュメントを 編集するものではなく テキスト要素を レイアウトにenumerateする際 テキストコンテンツマネージャに対して コメントをスキップするよう リクエストするものです
TextKit 2はmacOS同様 iOSにもよく対応しています つまりTextKit 2で作成した macOS Appの一部を iOS版でも利用できます iPadで見てみましょう
コラボAppのiOS版で 同じ機能を実装する コードを使用しました コメントを残すため 文を長押します そして「それいいね」と入力して そして「Enter」します
macOS版と同様に コメントの 「表示/非表示」ボタンをタップし コメントを全て非表示にできます
TextKit 2を利用した Appを通じて テキスト操作とレイアウトを 実践してきました このサンプルAppを例にコードと TextKit 2の役割を確認しましょう
このAppではTextKit 2が提供する 数々の機能を実現していますが ここでは2つの点に焦点を当てます NSViewportLayoutController による ビューポートでのテキストレイアウト そしてカスタム設定した コメントの非表示設定と レンダリングの実装です
ドキュメントに変更があったか コンテナのサイズが変更したか またはそれまで未表示だった ドキュメントの部分が ビューポートに移動したか いずれかの理由で レイアウトマネージャが ドキュメントをレイアウトする際 textViewportLayout ControllerWillLayoutを ビューポートレイアウトデリゲートに 呼びます ここではテキスト サブレイヤーを全て解除し アニメーショントランザクションを 起動するために使います
テキストレイアウトマネージャ が担当する 各テキスト要素について textViewportLayoutController configureRenderingSurfaceFor textLayoutFragment を呼び出します ここではジオメトリの更新や 新しい位置へのアニメーション そして可能であれば ビューのサブレイヤーへの追加のために テキストレイアウトフラグメントを 表示するレイヤーを取得します 描画も それをサブレイヤーとして 加える
レイアウトマネージャは レイアウト完了後 textViewportLayout ControllerDidLayoutを呼びます アニメーションの移行をコミットし セレクションのハイライトを更新 そしてコンテンツサイズも更新し Scroll Thumbが 正しく配置されるようにします
コメントについて 話しましょう TextKit 2は レイアウト要素のカスタマイズと レイアウトフラグメントの 生成に利用できる 複数のフックを提供しています 今から ドキュメントからコメントを抽出し フォントやカラー等の カスタム属性を設定したり その背後のバブルを描画する 方法を紹介します
ドキュメントの各パラグラフでは テキストコンテンツストレージが そのパラグラフの属性を カスタマイズするための機会を デリゲートに与えます この実装では 基層のテキストストレージの フォントやカラーを変えることなく コメントのフォントやカラーだけを カスタマイズできる設定です
またテキストコンテンツマネージャは レイアウト時どのテキスト要素を テキストレイアウトマネージャに 提示するかを判断するチャンスを デリゲートに与えます
テキスト要素に対し Falseを返すと その要素の表示を防ぎます コメントをenumerateしないことで 基層のテキストストレージから 削除せずに コメントを非表示にすることができます
テキストレイアウトマネージャ にもデリゲートがあります textElementに textLayoutManager textLayoutFragmentFor location を実装することで デリゲートは 特定のNSTextElementに対して デフォルトの NSTextLayoutFragment インスタンスではなく カスタムテキストレイアウト フラグメントを生成できます この場合 コメントを司る NSTextElementに 遭遇すると NSTextLayoutFragment のサブクラスである BubbleLayoutFragment を作成します
BubbleLayoutFragmentは NSTextLayoutFragmentの 描画メソッドをオーバーライドし ベースクラスの実装を 呼び出す前に テキストを 背景バブルに重ねて描画します またテキストは そして先程設定した カスタムフォントと テキストカラーで レンダリングされます
ここまで サンプルAppを通じて TextKit 2での ビューポートベースのテキストの アニメーションレイアウトと カラフルなバブルのコメントの レンダリングを テキストストレージのカスタム属性から カスタム描画まで 確認してきました サンプルコードには他にも TextKit 2の新しいAPIの利点を 活用するための内容が 豊富に含まれています テキスト選択を決定する マウスの動きの解釈や テキスト選択ハイライトのレンダリング また 特定のパラグラフへの コメントポップオーバーの配置 そしてドキュメントの高さの推定など これらのトピックの詳しい情報を サンプルコード内で確認できます ではDonnaから TextKit 2に向けたAppの準備方法を 紹介してもらいます ありがとうChris TextKit 2の実装例の とても良い紹介でした TextKit 2の魅力について 理解したところで Appの最新化への アプローチについて話します
ここまでお話した内容は 一般的なビューやレイヤーに使う 独自のTextKit 2スタックの 作成に適用します iOS 15のUIKitまたは macOS 12のAppKitで 新しいクラスの全てが入手可能です このアプローチを選ぶ場合は 今すぐTextKit 2で 新しいコードを書き始められます
一方 多くのAppはテキストビューなどの テキストコントロールを使っており アクセシビリティサポートや 選択・編集サービスなどの 優れた機能を無料で利用できます これらのコントロールの一部は TextKit 2向けに 既にアップデートされています Appでビルトインコントロールを 採用するなら 下記のような追加の詳細に 注意してください 互換性の維持は Appleにとっても皆さんにとっても 重要です TextKit 1は ビルトインコントロールの 重要な要素だったことを踏まえ これを使用しているAppでの 互換性の維持に注力する必要があります iOS 15とmacOS 12で 一部のコントールだけが 自動的にTextKit 2を利用するのは このためです
さらに 一部のコントロールがこれらの OSバージョンでTextKit 2を使うには 追加のステップが必要です AppKitデベロッパに関して NSTextViewはTextKit 2を 自動的に使用しません
NSTextViewに対応したTextKit 2を 使いたい場合は 作成時にコード上で オプトインする必要があります
以下がその方法です まずテキストレイアウト マネージャを作成します 次にテキストコンテナを作成します そしてテキストコンテナを NSTextLayoutManagerの textContainerプロパティを使って テキストレイアウトマネージャに 関連付けます 最後に ここでテキストコンテナの 指定イニシャライザを使って NSTextViewを作成します これでTextKit 2を使用した テキストビューができます
NSTextViewでの 新しいプロパティで テキストレイアウトマネージャと テキストコンテンツストレージに アクセスできます でも1つ注意が必要です
NSTextViewには NSLayoutManagerの 取得と設定を許可する layoutManagerプロパティがあります
NSLayoutManagerは TextKit 1のオブジェクトで TextKit 2スタックとの互換性は ありません
テキストビューでは レイアウトマネージャと テキストレイアウトマネージャを 同時に利用できません
そのため 必要な場面で TextKit 1に切り替えるための 特別な互換性モードを NSTextViewに 追加しました テキストビューは このモードが 必要な場面を 自動的に検知し NSTextLayoutManagerを NSLayoutManagerに 置き換えます パフォーマンスの 最適化のために その後のテキストビューは 互換性モードを 維持します
仮にTextKit 2をオプトインしても テキストビューやテキストコンテナに layoutManagerプロパティを 明示的に呼び出す場合 テキストビューは自動的に TextKit 1に切り替わります
テキストが未対応だったり TextKit 1が必要な条件が 検知されたりした場合も テキストビューが切り替わります
同様のことがフィールドエディタでも 起こります NSTextFieldのフィールドエディタは デフォルトでTextKit 2を使いますが テキストフィールドのサブクラスが フィールドエディタの レイアウトマネージャから レイアウト情報をリクエストすると フィールドエディタはウィンドウ内の 全テキストフィールドで TextKit 1に切り替わります
TextKit 1に切り替わる前と後に システムが通知します これらの通知に含まれる 情報も確認できます
この通知オブジェクトには モード変更の対象となる テキストビューへの レファレンスが含まれます
AppKitの TextKit 1互換性モードに関する 完全な詳細情報は Apple Developerポータルの ドキュメントを参照してください
UIKitデベロッパの場合 UITextFieldはiOS 15で 自動的にTextKit 2を使います
TextKit 2のUITextViewは iOS 15では利用できません
Appleでは UITextViewを使う 数々のAppすべてに関して できる限りの互換性を 保証できるよう努めています その間 デベロッパ側では UITextViewの layoutManagerプロパティについて 既存のコードを確認しつつ TextKit 2でのインテントの 表現方法を検討してください そうすれば TextKit 2に合わせて スムーズに移行できるでしょう
では以上になります Appleが未来へ導く 最新テキストエンジン TextKit 2について ご理解いただけたと思います 皆さんがこれから TextKit 2で構築するAppを 楽しみにしています ありがとうございました [陽気な音楽]
-
-
32:22 - Responding to layout updates: textViewportLayoutControllerWillLayout()
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) { contentLayer.sublayers = nil CATransaction.begin() }
-
32:47 - Responding to layout updates: textViewportLayoutController(_:configureRenderingSurfaceFor:)
private func animate(_ layer: CALayer, from source: CGPoint, to destination: CGPoint) { let animation = CABasicAnimation(keyPath: "position") animation.fromValue = source animation.toValue = destination animation.duration = slowAnimations ? 2.0 : 0.3 layer.add(animation, forKey: nil) } private func findOrCreateLayer(_ textLayoutFragment: NSTextLayoutFragment) -> (TextLayoutFragmentLayer, Bool) { if let layer = fragmentLayerMap.object(forKey: textLayoutFragment) as? TextLayoutFragmentLayer { return (layer, false) } else { let layer = TextLayoutFragmentLayer(layoutFragment: textLayoutFragment, padding: padding) fragmentLayerMap.setObject(layer, forKey: textLayoutFragment) return (layer, true) } } func textViewportLayoutController(_ controller: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) { let (layer, layerIsNew) = findOrCreateLayer(textLayoutFragment) if !layerIsNew { let oldPosition = layer.position let oldBounds = layer.bounds layer.updateGeometry() if oldBounds != layer.bounds { layer.setNeedsDisplay() } if oldPosition != layer.position { animate(layer, from: oldPosition, to: layer.position) } } if layer.showLayerFrames != showLayerFrames { layer.showLayerFrames = showLayerFrames layer.setNeedsDisplay() } contentLayer.addSublayer(layer) }
-
33:10 - Responding to layout updates: textViewportLayoutControllerDidLayout()
func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) { CATransaction.commit() updateSelectionHighlights() updateContentSizeIfNeeded() adjustViewportOffsetIfNeeded() }
-
33:47 - Overriding text attributes for comments
func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? { // In this method, we'll inject some attributes for display, without modifying the text storage directly. var paragraphWithDisplayAttributes: NSTextParagraph? = nil // First, get a copy of the paragraph from the original text storage. let originalText = textContentStorage.textStorage!.attributedSubstring(from: range) if originalText.attribute(.commentDepth, at: 0, effectiveRange: nil) != nil { // Use white colored text to make our comments visible against the bright background. let displayAttributes: [NSAttributedString.Key: AnyObject] = [.font: commentFont, .foregroundColor: commentColor] let textWithDisplayAttributes = NSMutableAttributedString(attributedString: originalText) // Use the display attributes for the text of the comment itself, without the reaction. // The last character is the newline, second to last is the attachment character for the reaction. let rangeForDisplayAttributes = NSRange(location: 0, length: textWithDisplayAttributes.length - 2) textWithDisplayAttributes.addAttributes(displayAttributes, range: rangeForDisplayAttributes) // Create our new paragraph with our display attributes. paragraphWithDisplayAttributes = NSTextParagraph(attributedString: textWithDisplayAttributes) } else { return nil } // If the original paragraph wasn't a comment, this return value will be nil. // The text content storage will use the original paragraph in this case. return paragraphWithDisplayAttributes }
-
34:06 - Hiding comments
func textContentManager(_ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, with options: NSTextElementProviderEnumerationOptions) -> Bool { // The text content manager calls this method to determine whether each text element should be enumerated for layout. // To hide comments, tell the text content manager not to enumerate this element if it's a comment. if !showComments { if let paragraph = textElement as? NSTextParagraph { let commentDepthValue = paragraph.attributedString.attribute(.commentDepth, at: 0, effectiveRange: nil) if commentDepthValue != nil { return false } } } return true }
-
34:28 - Generating special layout fragments for comments
func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement) -> NSTextLayoutFragment { let index = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: location) // swiftlint:disable force_cast let commentDepthValue = textContentStorage!.textStorage!.attribute(.commentDepth, at: index, effectiveRange: nil) as! NSNumber? if commentDepthValue != nil { let layoutFragment = BubbleLayoutFragment(textElement: textElement, range: textElement.elementRange) layoutFragment.commentDepth = commentDepthValue!.uintValue return layoutFragment } else { return NSTextLayoutFragment(textElement: textElement, range: textElement.elementRange) } }
-
34:58 - Drawing the comment bubble
var commentDepth: UInt = 0 private var tightTextBounds: CGRect { var fragmentTextBounds = CGRect.null for lineFragment in textLineFragments { let lineFragmentBounds = lineFragment.typographicBounds if fragmentTextBounds.isNull { fragmentTextBounds = lineFragmentBounds } else { fragmentTextBounds = fragmentTextBounds.union(lineFragmentBounds) } } return fragmentTextBounds } // Return the bounding rect of the chat bubble, in the space of the first line fragment. private var bubbleRect: CGRect { return tightTextBounds.insetBy(dx: -3, dy: -3) } private var bubbleCornerRadius: CGFloat { return 20 } private var bubbleColor: Color { return .systemIndigo } private func createBubblePath(with ctx: CGContext) -> CGPath { let bubbleRect = self.bubbleRect let rect = min(bubbleCornerRadius, bubbleRect.size.height / 2, bubbleRect.size.width / 2) return CGPath(roundedRect: bubbleRect, cornerWidth: rect, cornerHeight: rect, transform: nil) } override var renderingSurfaceBounds: CGRect { return bubbleRect.union(super.renderingSurfaceBounds) } override func draw(at renderingOrigin: CGPoint, in ctx: CGContext) { // Draw the bubble and debug outline. ctx.saveGState() let bubblePath = createBubblePath(with: ctx) ctx.addPath(bubblePath) ctx.setFillColor(bubbleColor.cgColor) ctx.fillPath() ctx.restoreGState() // Draw the text on top. super.draw(at: renderingOrigin, in: ctx) }
-
37:26 - Opting NSTextView in to TextKit 2
var scrollView: NSScrollView! var containerSize = CGSize.zero var textContainer = NSTextContainer() // Important: Keep a reference to text storage since NSTextView weakly references it. var textContentStorage = NSTextContentStorage() override func viewDidLoad() { super.viewDidLoad() scrollView = NSScrollView(frame: NSRect(origin: CGPoint(), size: CGSize(width: view.bounds.width, height: view.bounds.height))) scrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scrollView) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: (view.leadingAnchor)), scrollView.trailingAnchor.constraint(equalTo: (view.trailingAnchor)), scrollView.topAnchor.constraint(equalTo: (view.topAnchor)), scrollView.bottomAnchor.constraint(equalTo: (view.bottomAnchor)) ]) setUpScrollView(scrollsHorizontally: false) } func setUpScrollView(scrollsHorizontally: Bool) { scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = scrollsHorizontally setUpTextContainer(scrollsHorizontally: scrollsHorizontally) setUpTextView(scrollsHorizontally: scrollsHorizontally) } func setUpTextContainer(scrollsHorizontally: Bool) { let contentSize = scrollView.contentSize if scrollsHorizontally { containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textContainer.containerSize = containerSize textContainer.widthTracksTextView = false } else { containerSize = NSSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude) textContainer.containerSize = containerSize textContainer.widthTracksTextView = true } } func setUpTextView(scrollsHorizontally: Bool) { let textLayoutManager = NSTextLayoutManager() textLayoutManager.textContainer = textContainer textContentStorage.addTextLayoutManager(textLayoutManager) // Workaround: Pass textLayoutManager.textContainer to the NSTextView initializer let textView = NSTextView(frame: scrollView.contentView.bounds, textContainer: textLayoutManager.textContainer) textView.isEditable = true textView.isSelectable = true textView.minSize = CGSize() textView.maxSize = containerSize textView.isVerticallyResizable = true textView.isHorizontallyResizable = scrollsHorizontally textContentStorage.performEditingTransaction { textView.textStorage?.append(NSAttributedString(string: "Text content...")) } scrollView.documentView = textView }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。