ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
SwiftUIによるカスタムビジュアルエフェクトの作成
SwiftUIで、息を呑むようなビジュアルエフェクトを作成しましょう。比類のないスクロールエフェクト、リッチな色処理、カスタムトランジションを構築する方法をご紹介します。Metalシェーダとカスタムテキストレンダリングを使用する高度なグラフィックエフェクトについても確認します。
関連する章
- 0:00 - Introduction
- 1:29 - Scroll effects
- 6:21 - Color treatments
- 9:10 - View transitions
- 12:49 - Text transitions
- 19:40 - Metal shaders
- 25:28 - Next steps
リソース
-
ダウンロード
「SwiftUIによるカスタム ビジュアルエフェクトの作成」へようこそ Philipと申します 後でRobも加わります 今回はビジュアルエフェクト つまり視覚効果を作成して 表現豊かで使いやすい アプリを作成する方法を紹介します 多くの場合 優れたアプリ体験を構築するには 沢山の小さな改善が必要です 細かな積み重ねが 大きな違いを生み出します 視覚効果はアプリの使い勝手や印象に 大きな影響を与える場合があります 例えば機能が正常に 動作していることを示すほか アプリの表示に人間味を持たせたり 重要な出来事がある場合に 注意を引いたりすることもできます 新しい視覚効果を作成する時は 何がうまくいくか 作ってみるまで わからないこともよくあります しっくりくるまで アイデアを色々試して 手直しする必要があります このセッションでは Robと私が例を示しながら SwiftUIの使い方を説明します 具体的な内容は カスタムのスクロール効果を作成する方法 メッシュグラデーションで リッチな色処理をアプリに加える方法 カスタムのビュートランジションを 作成する方法 テキストレンダリングを使って 美しい テキストトランジションを作成する方法 そしてMetalシェーダを記述して 高度なグラフィック効果を作成する方法です 最初に取り上げるのは おそらく皆さんもよくご存知の スクロールです アプリ体験の大半を占めるのは スクロールして見る アイテムのコレクションです 写真かビデオか テキストブロックかを問わず スクロールビューはいたる所にあります
この横方向のスクロールビューには シンプルな写真のコレクションがあります SwiftUIのスクロールビューは 一般的な 用途の自動機能を多数備えています
ここではページング動作を使って ページネーション効果を得ています 通常のスクロールビューには これで十分ですが ここではもっとユニークなものを 作りたいと思います
1枚の写真を見てみましょう
SwiftUIのscrollTransition モディファイア(修飾子)では 標準的な要素のコレクションを変更して カスタマイズできます
scrollTransitionには トランジションさせたいコンテンツと フェーズがあります
これらの値を使うと スクロールビュー内の各写真の 回転角度やオフセットを その位置に基づいて 変更できます
こうするとスクロールした時に 最初から最後までの写真が順番に 回って見えるカルーセル効果が生まれます
valueプロパティを使うと 画像がどのくらい画面から出ているか判断し それを回転に使用できます ビュー全体が画面上にある場合は isIdentityプロパティがtrueになります
この回転効果は悪くないのですが ここでは少し違う表現を 使ってみたいと思います これらのカードを見ている時に 窓から覗いているような 感じにしたいのです
モディファイアを変更すると scrollTransitionが更新されます このスクロールビューの雰囲気を すっかり変えて パララックス効果を生み出そうと思います
ここではscrollTransitionを使って 画像のxOffsetを変更していますが クリッピングの形状は変更していません scrollTransitionを使うと 様々な方法でこのコンテンツを 操作できます
このモディファイアは スクロール値に基づいて更新したい あらゆるコンテンツに適用できます ここではscrollTransitionを使って 画像の下のテキストを フェードアウトさせると同時に オフセットして スクロールビューに勢いを持たせています
scrollTransitionは 興味を引くユニークなスクロール体験を 構築するのに最適な方法です ただし ビューの位置やサイズに応じて ビューの外観をどう変えるかを もっと細かく制御したい場合もあります
こちらにはスクロールできる シンプルな食料品のコレクションがあります 今はすべてのアイテムが 同じ色で単調に見えます
visualEffectモディファイアを追加すると コンテンツプレースホルダとプロキシに アクセスできます コンテンツプレースホルダは scrollTransitionと同様に機能します プロキシにより ビューのジオメトリ値が得られます
プロキシから取得した ビューの位置情報を使って ビューの色相を変えることで 美しいグラデーション効果が生まれます デバイスでビューの下に行くほど 色相の回転が強くなります
visualEffectモディファイアは 視覚的プロパティを ビューの位置とサイズに基づいて 効率良く変更できるため スクロールビューでの使用に最適です
色だけではなく 他の視覚的プロパティも変更できます ここではまた図形のy座標を使って スクロールビューの一番上に到達した 要素のオフセット 縮小 フェードアウト ぼかしを行っています scrollTransitionモディファイアと visualEffectモディファイアは 独自のスクロールビュー効果を 作成するための優れた方法です
これらのモディファイアを使えば 画面上の要素の位置に基づいて 倍率を調整する スクロールビューを作成できます
回転や傾斜などの様々な変換を使って 遠近感を変えることもできます
オフセットで積み上げの動きを作ったり 明度 彩度 色相など 色のプロパティを調整して 強調したり明瞭化したり することもできます
ただし その効果が アプリに適しているか邪魔になるかは すぐにわかるとは限りません 視覚的な実験に 時間をかけることが大切です 視覚効果は目新しさが薄れた後も 快適に使えなければいけません 様々な状況で時間をかけて 効果をテストすることは 効果が機能しているか さらなる改善が必要かを 確かめるのに役立ちます 次はカラー効果をアプリに導入する 方法についてお話ししましょう
色はインターフェイスで 重要な役割を果たし アプリに独自性を与えたり注意を引いたり 意図を明確にするのにも役立ちます SwiftUIにはアプリで 色を操るためのツールが多数あります 各種のグラデーションや 色制御 ブレンドモードなどがサポートされています
SwiftUIで新たにサポートされたのが メッシュグラデーションです メッシュグラデーションは 背景を動的にしたい場合や 視覚的に目立つ表示に したい場合に便利です
メッシュグラデーションは 点の格子でできています それぞれの点には色が関連付けられています
SwiftUIは格子上のこれらの色を補間して 色の塗りつぶしを作成します
これらの点を移動すると 美しい色の効果を生み出せます 色は切れ目なく混ざり合い 点と点の距離が近いほど 色のトランジションが鮮明になります
メッシュグラデーションを作成するには 新しいMeshGradientビューを使います
widthパラメータと heightパラメータを使って 格子の行と列を定義します ここでは3×3の格子を使います
次に この3×3の格子で X座標とY座標が どこにあるかを定義します 格子内の点は SIMD2の浮動小数点値で定義されます ビューとして使う場合 これらの浮動小数点数はX軸上とY軸上の 0〜1の値を取ります
最後に 各点に対応する色を指定します
メッシュグラデーションができました 今のところ 線形グラデーションのように見えます 中心点のX座標とY座標を移動すると 新しい位置に合わせて色も動きます
メッシュグラデーションは 色の効果をアプリに加える優れた方法であり 様々な視覚効果を作り出すために使えます 純粋に装飾的なものだけでなく 画像に合わせた表示にするために使ったり メッシュグラデーションのアニメーションで 状況の変化を知らせることもできます
制御点の位置や 格子サイズ カラーパレットなどの値を 色々と試してみてください パラメータを微調整して 限界まで視覚的な可能性を探れば 最初に思いついたアイデアを はるかに超える表現を実現できます あらゆる可能性を追求して 新しいものを生み出しましょう 続いてはカスタムのトランジションを 作成する方法についてです アプリのインターフェイスは アプリが背後で行っていることへの窓口です そしてトランジションは 起こっている変化を伝えるための 便利な方法です
トランジションは 新しいビューを表示したい時や 不要になったビューを 削除する時に効果的です
何が変わったのか なぜ変わったのかというコンテキストを トランジションによって提供できます こうしたトランジションはボタンのタップや 要素のドラッグによって 発生する場合もあれば アプリを使っている他のユーザーの行動が 契機となることもあります
ユーザーのオンラインステータスに基づいて 表示を切り替えるアバタービューがあります ユーザーがオンラインの時は そのアバターを表示し そうでない時は非表示にしたいと思います
今はただ現れて消えるだけです 少し違和感があるので トランジションを追加しましょう
scaleなど SwiftUIの標準的な トランジションを適用して 現れる時と消える時に 拡大縮小させることができます
複数のトランジションを変更したい場合は combinedメソッドを使って 別のトランジションを追加できます 拡大縮小のトランジションに 不透明度を組み合わせてみましょう
見栄えはよくなりましたが さらにカスタマイズしたい場合もあります
カスタムのトランジションを作るために 新しい構造体を作成します Twirlという名前にします これはTransitionプロトコルに 準拠します
Transitionのbody関数はcontentと phaseのパラメータを受け取ります contentパラメータは スクロールビューの場合と同じように トランジションさせたいコンテンツの プレースホルダとして機能します phase値を使って ビューが現在表示されているか確認し 条件に応じて ビューのスタイルを設定できます
拡大縮小については ビューが表示されている時は実物大に 表示されていない時は2分の1にします
不透明度については 要素の完全な表示と 非表示とで切り替えるようにします
このカスタムのトランジションを ビューにアタッチして 結果を確認しましょう
カスタムのトランジションに戻って ぼかしを追加します これでアバターが はっきり見えたりぼやけて見えます 回転を加えて回すこともできます
phase値を使うと ビューが表示されるのか または非表示になったのかを 確認できます このように負の値を使えば 消える時も同じ方向に 回転させることができます
最後に brightnessモディファイアを 追加します これでビューが表示される時に 少し輝くので 注意を引くことができます
ちょっとした調整を加えるだけで インターフェイス要素をなめらかに 変化に対応させることができます
トランジションは様々な場面で使えます 要素を違和感なくビューに読み込んだり
重要な情報を提示したり グラフィック要素に 躍動感を与えたりもできます
優れたトランジションがあれば 大きなコンテキストに自然となじむので 取って付けた感じになりません アプリを総合的に見ると どのようなトランジションがアプリに 適しているか判断しやすくなります トランジションの続きとして この後はRobから テキストトランジションについて 話してもらいます
ありがとう Phillip では始めましょう
この不透明度のトランジションのように SwiftUIに組み込まれたトランジションで ビューをアニメーション化する方法は Philipが説明してくれました 組み込みのモディファイアを使って 面白みを加えるのもいいのですが ここではテキストを1行ずつ アニメーション化したいと思います
これにはTextRendererを使います これはiOS 18と対応リリースで 導入された新しいAPIです TextRendererはビューツリー全体の SwiftUIテキストの描画方法を カスタマイズできる 新しい強力なプロトコルです これにより まったく新しい カスタムのテキスト描画が可能になりますが 最も注目される点はアニメーションです
TextRendererプロトコルの中核は draw(layout:in:)メソッドです その引数はText.Layoutと GraphicsContextです Text.Layoutを使うと テキストの構成要素である 行、ラン、グリフにアクセスできます GraphicsContextはキャンバスビューで 使われているものと同じ型です これを使って描画する方法について 詳しくは「Add rich graphics to your SwiftUI app」をご覧ください 最小限のTextRendererであれば forループを使って レイアウトの個々の行を 反復処理し それを コンテキストに描画するだけです これがデフォルトの レンダリング動作になります
トランジションを強化するために TextRendererにプロパティを3つ加えます elapsedTimeは これまでに経過した時間です elementDurationは個々の行や文字の アニメーション化に費やす時間です そしてtotalDurationは トランジション全体にかかる時間です SwiftUIでelapsedTime値を 自動的にアニメーション化するには Animatableプロトコルを実装します この場合の導入方法は簡単で animatableDataプロパティを elapsedTimeに送るだけです
次はアニメーションの反復処理です まず1行ずつアニメーション化します アニメーション全体で 使用できる時間を均等に配分するには ここで呼び出した elementDelay(count:)という ヘルパー関数を使って 連続する2行の間の遅延を 計算する必要があります 次にすべての行を列挙し そのインデックスと遅延値に基づいて 各行の相対的な開始時間を計算します 各行の経過時間は 全体の経過時間から その要素の時間オフセットを 差し引いたものです この値も固定します 次に 現在のグラフィックスコンテキストの コピーを作成します GraphicsContextは 値セマンティクスを持つので これでヘルパー関数の呼び出しが 影響し合うことがなくなります
最後にヘルパー関数を 呼び出して各行を描画します
ここで実際に形にするわけです 行を描画する前に アニメーション化する GraphicsContextの プロパティを更新します 簡単にするために 進捗を表す分数値も計算します
まず行をフェードインさせたいので 不透明度の急な勾配を計算します
同時に ぼかし半径を0に減らして 拡散した状態から 行が現れる印象を作ります
初期のblurRadiusは 行のtypographicBoundsプロパティから 読み取った行の高さに基づきます
さらに ばねを使って y軸上の移動をアニメーション化します
行のディセンダの長さに基づいて 上にシフトしたy位置から開始します 最後に 新しいdraw optionsメソッドを 使って行を描画します
サブピクセルの量子化を無効にすることで ばねが止まる時の揺らぎをなくせます
テキストでレンダラを使って アニメーション化するには Philipが先ほど説明したように カスタムのトランジションを実装します 試してみたところ この場合は0.9秒が 適切な再生時間であるとわかりました ただし 現在のトランザクションに 既存のアニメーションがある可能性を 考慮する必要があります 例えば withAnimationの呼び出しから このトランジションがトリガされた時に
トランザクションの bodyビューモディファイアを使って アニメーションを 必要に応じてオーバーライドできます これですべての行が 同じ一定のペースになります 次に 新しいtextRendererビュー モディファイアを使って トランジションしながら現れて消える ビューにカスタムのレンダラを設定します
こちらがこのトランジションの動作です
まあまあといった感じですね これは行数に依存していますが ロケールやDynamic Typeのサイズによって 行数は変わる可能性があります また 視覚効果への 期待が大きいだけに物足りません グリフごとに アニメーション化してみましょう
そのためにはText.Layoutの ランスライスを反復処理する必要があります ランスライスは レイアウトの最小単位を表します グリフや埋め込み画像などです
Text.Layoutは行のコレクションです 行はランのコレクションで ランはランスライスのコレクションです
したがってflattenedRunSlicesという このヘルパーメソッドを使用すれば 代わりにランスライスを 反復処理すればいいだけなので ほぼすべてのロジックを維持できます
ヘルパー関数も見直す必要がありますが そのLine引数の型と名前を RunSliceに変更するだけで 済みます
こちらが変更後の動作です よくなったと思うのですが 今度は逆の問題があります アニメーションで 個々のグリフに割り当てる時間が ほとんどないのです そのため全体的なインパクトが乏しく トランジションの面白さが薄れて ちょっと単調に感じますよね 少し考え直す必要がありそうです すべてを同じように アニメーション化するのではなく 「Visual Effects」という言葉だけに 焦点を当ててみましょう
そうすればトランジションを使って コンテンツを提示するだけでなく 重要な点を強調することもできます
そのためには iOS 18と対応リリースで TextRendererと共に導入された 新しい TextAttributeプロトコルを使います このプロトコルを実装すると TextからTextRendererにデータを 渡すことができます
この属性の適用はとても簡単です customAttribute テキストモディファイアを使い カスタムのEmphasisAttributeによって Visual Effectsという言葉をマークします これはテキストの一部を マークするためだけに使うので メンバー変数をTextAttribute構造体に 追加する必要はありません
最後にもう一度drawメソッドを見直して 今度はレイアウトの flattenedRunsを反復処理します 属性と型をキーとする添え字を使用して EmphasisAttributeが ランに存在するかどうか確認します
この属性が存在する場合は 先ほどとまったく同じ方法で スライスを反復処理します この属性が存在しない場合は ランを0.2秒で すばやくフェードインします
こちらが最終結果です 随分よくなりました Visual Effectsがトランジションで 強調されるようになりました
TextRendererは あらゆる新しい可能性を開きます 個別にアニメーション化する 小さな構成要素にビューを分割することで 表現豊かなアニメーションや 視覚効果を構築できます SwiftUIには さらにきめ細かな制御を可能にする 強力なグラフィックスAPIが もう1つあります シェーダです シェーダは様々なレンダリング効果を デバイスのGPU上で直接計算する 小さなプログラムです SwiftUIでは内部的にシェーダを使い 新しいメッシュグラデーションなどの 先ほどPhilipが紹介した様々な 視覚効果を実装します iOS 17と対応リリースで導入された SwiftUIのシェーダにより 同じレベルのパフォーマンスを実現しながら 独自の印象的な効果を作成できます
SwiftUIでシェーダを インスタンス化するには ShaderLibraryで その名前を使って関数を呼び出します ここでは色 数値 画像などの 追加パラメータを シェーダ関数に渡すこともできます layerEffectビューモディファイア を使って この効果をビューに適用すると SwiftUIはビューの1つ1つのピクセル について シェーダ関数を呼び出します
ピクセル数は膨大です この処理をリアルタイムで行うために こうした高度な並列タスク用に最適化された デバイスのGPU上で シェーダが実行されます ただし GPUプログラミングは 特殊な性質を持っているため シェーダそのものを Swiftで記述することはできません 代わりに使うのが Metal Shading Language 略してMetalです
こちらは先ほど紹介した シェーダに対応するMetalファイルです シェーダ関数の名前は ShaderLibrabryでの呼び出し名と同じです
これはGPU上でビューピクセルごとに SwiftUIが実行する関数であり 実行時にposition引数は そのピクセルの位置を指します 一方 layer引数は ビューのコンテンツを表します layerをサンプリングして そのコンテンツを取得できますが positionを基準として シェーダがインスタンス化された maxSampleOffset内に 収める必要があります
また SwiftUIはColorなどの型を解決し Metalで使用できる表現に変換します ここではピンク色が half4に変換されています
Metalでは このようなベクトル型が多用されます half4は16ビット浮動小数点数の 4成分からなるベクトルです この型は色の赤 緑 青 アルファの 各成分をエンコードします 同様に float2は 32ビット浮動小数点数の 2成分からなるベクトルであり 2Dの点や次元によく使われます
SwifUIのシェーダはカスタム塗りつぶしと 3種類の効果に使えます カラー効果 歪み効果 レイヤ効果です
3つの効果のうち レイヤ効果は最も強力で 実質的に他の2つの スーパーセットと言えるので レイヤ効果の記述方法をお見せしましょう
現在 タップするたびにトリガされる このPushEffectを ビューに組み込んでいます ビューはばねを使って縮んでから すぐ元の大きさに戻ります ここでは操作に対する 直接的な反応が得られますが タッチした場所に応じた アニメーションはありません そのため機械的で硬い感じがします
代わりに こんな風に 見えるようにしたいと思います ビューをタッチするたびに 拡大縮小の効果がタッチ位置から 外側に広がります ビューのピクセルによって 作用が異なるわけです SwiftUIシェーダというツールがあるので このような効果を実現できます
この効果を実装するために Metalファイルに Rippleという新しいシェーダ関数を 追加します レイヤ効果APIに必要な2つの引数として positionとlayerを追加します
各ピクセルの出力を表す 式はすでに作ってあります これはビューがタッチされた位置 経過時間 そして これら4つのパラメータの関数です
このピクセルの歪みを計算して このnewPosition値を得ます ここでビューをサンプリングします
歪みの強さに基づいて微調整した後 変更した色を返します 次にこのシェーダ関数を SwiftUIから呼び出す必要があります
そのためにRippleModifierという ViewModifierを作成し シェーダ関数のパラメータを すべてSwiftUIに公開します body(content:)メソッドでは シェーダをインスタンス化して そのコンテンツに適用します
シェーダには時間の概念がないので アニメーションもSwiftUIから 制御する必要があります
その方法として RippleEffectという 2つ目のViewModifierを作成しました keyframeAnimatorビューモディファイア により ジェスチャなど 外部の変化に基づいて アニメーションを簡単に実行できます トリガ値が更新されるたびに 0から再生時間の最終的な値まで elapsedTimeをアニメーション化します これにより アニメーションの各ステップで 現在の時間と ビューのタッチ位置である原点が RippleModifierに渡されます
ところで 先ほどの4つのパラメータに 値を一度も代入しませんでした 正直なところ どのような値が適切かわからないのです 試してみるしかないので このデバッグUIを独自に作りました
RippleModifierは どんなアニメーションでも実行するので これを使って 色々な設定でインタラクティブに アニメーションを動かせます これにより iPhone上や Xcodeプレビュー内で シェーダ関数の適切なパラメータを 見つけることができます
優れた体験を構築するには 多くの試行錯誤が必要ですが 複雑なアニメーションを繰り返す上で デバッグUIは大いに役立ちます 例えば パラメータを公開したり 中間値を視覚化する オーバーレイを描画したりできます このように即座にフィードバックが 得られるのは非常に効果的で すばやい繰り返しが容易になります シェーダで生み出せるものには 非常に大きな可能性があるため これは重要なことです
シェーダを使うと アニメーション表示の塗りつぶしを作って アプリに質感を与えられます シェーダとTextRendererを組み合わせて テキストに歪みを適用したり グラデーションマップを作成して 写真に独特な効果を加えることもできます
このビデオでは SwiftUIで視覚効果を作成する 様々な方法を紹介しました これらのアイデアを 皆さんなりにアレンジしてみてください
カスタムのスクロール効果を試して アプリを際立たせましょう メッシュグラデーションで 色彩を添えましょう カスタムのビュートランジションを アプリに加えましょう 新しいテキストレンダラAPIで テキストを生き生きと表現しましょう Metalシェーダで まったく新しい体験を構築しましょう
これらのツールで 新たな価値を生み出してください ご視聴ありがとうございました
-
-
1:45 - Scroll view with pagination
ScrollView(.horizontal) { LazyHStack(spacing: 22) { ForEach(animals, id: \.self) { animal in AnimalPhoto(image: animal) } }.scrollTargetLayout() } .contentMargins(.horizontal, 44) .scrollTargetBehavior(.paging)
-
2:30 - Rotation effect
AnimalPhoto(image: animal) .scrollTransition( axis: .horizontal ) { content, phase in content .rotationEffect(.degrees(phase.value * 2.5)) .offset(y: phase.isIdentity ? 0 : 8) }
-
3:14 - Parallax Effect
ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(animals, id: \.self) { animal in VStack(spacing: 8) { ZStack { AnimalPhoto(image: animal) .scrollTransition( axis: .horizontal ) { content, phase in return content .offset(x: phase.value * -250) } } .containerRelativeFrame(.horizontal) .clipShape(RoundedRectangle(cornerRadius: 32)) } }.scrollTargetLayout() } .contentMargins(.horizontal, 32) .scrollTargetBehavior(.paging)
-
4:41 - Visual effect hue rotation
RoundedRectangle(cornerRadius: 24) .fill(.purple) .visualEffect({ content, proxy in content .hueRotation(Angle(degrees: proxy.frame(in: .global).origin.y / 10)) })
-
7:30 - Mesh gradient
MeshGradient( width: 3, height: 3, points: [ [0.0, 0.0], [0.5, 0.0], [1.0, 0.0], [0.0, 0.5], [0.9, 0.3], [1.0, 0.5], [0.0, 1.0], [0.5, 1.0], [1.0, 1.0] ], colors: [ .black,.black,.black, .blue, .blue, .blue, .green, .green, .green ] )
-
10:36 - Custom transition
struct Twirl: Transition { func body(content: Content, phase: TransitionPhase) -> some View { content .scaleEffect(phase.isIdentity ? 1 : 0.5) .opacity(phase.isIdentity ? 1 : 0) .blur(radius: phase.isIdentity ? 0 : 10) .rotationEffect( .degrees( phase == .willAppear ? 360 : phase == .didDisappear ? -360 : .zero ) ) .brightness(phase == .willAppear ? 1 : 0) } }
-
13:29 - The Minimum Viable TextRenderer
// The Minimum Viable TextRenderer struct AppearanceEffectRenderer: TextRenderer { func draw(layout: Text.Layout, in context: inout GraphicsContext) { for line in layout { context.draw(line) } } }
-
14:01 - A Custom Text Transition
import SwiftUI #Preview("Text Transition") { @Previewable @State var isVisible: Bool = true VStack { GroupBox { Toggle("Visible", isOn: $isVisible.animation()) } Spacer() if isVisible { let visualEffects = Text("Visual Effects") .customAttribute(EmphasisAttribute()) .foregroundStyle(.pink) .bold() Text("Build \(visualEffects) with SwiftUI 🧑💻") .font(.system(.title, design: .rounded, weight: .semibold)) .frame(width: 250) .transition(TextTransition()) } Spacer() } .multilineTextAlignment(.center) .padding() } struct EmphasisAttribute: TextAttribute {} /// A text renderer that animates its content. struct AppearanceEffectRenderer: TextRenderer, Animatable { /// The amount of time that passes from the start of the animation. /// Animatable. var elapsedTime: TimeInterval /// The amount of time the app spends animating an individual element. var elementDuration: TimeInterval /// The amount of time the entire animation takes. var totalDuration: TimeInterval var spring: Spring { .snappy(duration: elementDuration - 0.05, extraBounce: 0.4) } var animatableData: Double { get { elapsedTime } set { elapsedTime = newValue } } init(elapsedTime: TimeInterval, elementDuration: Double = 0.4, totalDuration: TimeInterval) { self.elapsedTime = min(elapsedTime, totalDuration) self.elementDuration = min(elementDuration, totalDuration) self.totalDuration = totalDuration } func draw(layout: Text.Layout, in context: inout GraphicsContext) { for run in layout.flattenedRuns { if run[EmphasisAttribute.self] != nil { let delay = elementDelay(count: run.count) for (index, slice) in run.enumerated() { // The time that the current element starts animating, // relative to the start of the animation. let timeOffset = TimeInterval(index) * delay // The amount of time that passes for the current element. let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration)) // Make a copy of the context so that individual slices // don't affect each other. var copy = context draw(slice, at: elementTime, in: ©) } } else { // Make a copy of the context so that individual slices // don't affect each other. var copy = context // Runs that don't have a tag of `EmphasisAttribute` quickly // fade in. copy.opacity = UnitCurve.easeIn.value(at: elapsedTime / 0.2) copy.draw(run) } } } func draw(_ slice: Text.Layout.RunSlice, at time: TimeInterval, in context: inout GraphicsContext) { // Calculate a progress value in unit space for blur and // opacity, which derive from `UnitCurve`. let progress = time / elementDuration let opacity = UnitCurve.easeIn.value(at: 1.4 * progress) let blurRadius = slice.typographicBounds.rect.height / 16 * UnitCurve.easeIn.value(at: 1 - progress) // The y-translation derives from a spring, which requires a // time in seconds. let translationY = spring.value( fromValue: -slice.typographicBounds.descent, toValue: 0, initialVelocity: 0, time: time) context.translateBy(x: 0, y: translationY) context.addFilter(.blur(radius: blurRadius)) context.opacity = opacity context.draw(slice, options: .disablesSubpixelQuantization) } /// Calculates how much time passes between the start of two consecutive /// element animations. /// /// For example, if there's a total duration of 1 s and an element /// duration of 0.5 s, the delay for two elements is 0.5 s. /// The first element starts at 0 s, and the second element starts at 0.5 s /// and finishes at 1 s. /// /// However, to animate three elements in the same duration, /// the delay is 0.25 s, with the elements starting at 0.0 s, 0.25 s, /// and 0.5 s, respectively. func elementDelay(count: Int) -> TimeInterval { let count = TimeInterval(count) let remainingTime = totalDuration - count * elementDuration return max(remainingTime / (count + 1), (totalDuration - elementDuration) / count) } } extension Text.Layout { /// A helper function for easier access to all runs in a layout. var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> { self.flatMap { line in line } } /// A helper function for easier access to all run slices in a layout. var flattenedRunSlices: some RandomAccessCollection<Text.Layout.RunSlice> { flattenedRuns.flatMap(\.self) } } struct TextTransition: Transition { static var properties: TransitionProperties { TransitionProperties(hasMotion: true) } func body(content: Content, phase: TransitionPhase) -> some View { let duration = 0.9 let elapsedTime = phase.isIdentity ? duration : 0 let renderer = AppearanceEffectRenderer( elapsedTime: elapsedTime, totalDuration: duration ) content.transaction { transaction in // Force the animation of `elapsedTime` to pace linearly and // drive per-glyph springs based on its value. if !transaction.disablesAnimations { transaction.animation = .linear(duration: duration) } } body: { view in view.textRenderer(renderer) } } }
-
22:55 - A simple ripple effect Metal shader
// Insert #include <metal_stdlib> #include <SwiftUI/SwiftUI.h> using namespace metal; [[ stitchable ]] half4 Ripple( float2 position, SwiftUI::Layer layer, float2 origin, float time, float amplitude, float frequency, float decay, float speed ) { // The distance of the current pixel position from `origin`. float distance = length(position - origin); // The amount of time it takes for the ripple to arrive at the current pixel position. float delay = distance / speed; // Adjust for delay, clamp to 0. time -= delay; time = max(0.0, time); // The ripple is a sine wave that Metal scales by an exponential decay // function. float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time); // A vector of length `amplitude` that points away from position. float2 n = normalize(position - origin); // Scale `n` by the ripple amount at the current pixel position and add it // to the current pixel position. // // This new position moves toward or away from `origin` based on the // sign and magnitude of `rippleAmount`. float2 newPosition = position + rippleAmount * n; // Sample the layer at the new position. half4 color = layer.sample(newPosition); // Lighten or darken the color based on the ripple amount and its alpha // component. color.rgb += 0.3 * (rippleAmount / amplitude) * color.a; return color; }
-
23:36 - A Custom Ripple Effect
import SwiftUI #Preview("Ripple") { @Previewable @State var counter: Int = 0 @Previewable @State var origin: CGPoint = .zero VStack { Spacer() Image("palm_tree") .resizable() .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 24)) .onPressingChanged { point in if let point { origin = point counter += 1 } } .modifier(RippleEffect(at: origin, trigger: counter)) .shadow(radius: 3, y: 2) Spacer() } .padding() } #Preview("Ripple Editor") { @Previewable @State var origin: CGPoint = .zero @Previewable @State var time: TimeInterval = 0.3 @Previewable @State var amplitude: TimeInterval = 12 @Previewable @State var frequency: TimeInterval = 15 @Previewable @State var decay: TimeInterval = 8 VStack { GroupBox { Grid { GridRow { VStack(spacing: 4) { Text("Time") Slider(value: $time, in: 0 ... 2) } VStack(spacing: 4) { Text("Amplitude") Slider(value: $amplitude, in: 0 ... 100) } } GridRow { VStack(spacing: 4) { Text("Frequency") Slider(value: $frequency, in: 0 ... 30) } VStack(spacing: 4) { Text("Decay") Slider(value: $decay, in: 0 ... 20) } } } .font(.subheadline) } Spacer() Image("palm_tree") .resizable() .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 24)) .modifier(RippleModifier(origin: origin, elapsedTime: time, duration: 2, amplitude: amplitude, frequency: frequency, decay: decay)) .shadow(radius: 3, y: 2) .onTapGesture { origin = $0 } Spacer() } .padding(.horizontal) } struct PushEffect<T: Equatable>: ViewModifier { var trigger: T func body(content: Content) -> some View { content.keyframeAnimator( initialValue: 1.0, trigger: trigger ) { view, value in view.visualEffect { view, _ in view.scaleEffect(value) } } keyframes: { _ in SpringKeyframe(0.95, duration: 0.2, spring: .snappy) SpringKeyframe(1.0, duration: 0.2, spring: .bouncy) } } } /// A modifer that performs a ripple effect to its content whenever its /// trigger value changes. struct RippleEffect<T: Equatable>: ViewModifier { var origin: CGPoint var trigger: T init(at origin: CGPoint, trigger: T) { self.origin = origin self.trigger = trigger } func body(content: Content) -> some View { let origin = origin let duration = duration content.keyframeAnimator( initialValue: 0, trigger: trigger ) { view, elapsedTime in view.modifier(RippleModifier( origin: origin, elapsedTime: elapsedTime, duration: duration )) } keyframes: { _ in MoveKeyframe(0) LinearKeyframe(duration, duration: duration) } } var duration: TimeInterval { 3 } } /// A modifier that applies a ripple effect to its content. struct RippleModifier: ViewModifier { var origin: CGPoint var elapsedTime: TimeInterval var duration: TimeInterval var amplitude: Double = 12 var frequency: Double = 15 var decay: Double = 8 var speed: Double = 1200 func body(content: Content) -> some View { let shader = ShaderLibrary.Ripple( .float2(origin), .float(elapsedTime), // Parameters .float(amplitude), .float(frequency), .float(decay), .float(speed) ) let maxSampleOffset = maxSampleOffset let elapsedTime = elapsedTime let duration = duration content.visualEffect { view, _ in view.layerEffect( shader, maxSampleOffset: maxSampleOffset, isEnabled: 0 < elapsedTime && elapsedTime < duration ) } } var maxSampleOffset: CGSize { CGSize(width: amplitude, height: amplitude) } } extension View { func onPressingChanged(_ action: @escaping (CGPoint?) -> Void) -> some View { modifier(SpatialPressingGestureModifier(action: action)) } } struct SpatialPressingGestureModifier: ViewModifier { var onPressingChanged: (CGPoint?) -> Void @State var currentLocation: CGPoint? init(action: @escaping (CGPoint?) -> Void) { self.onPressingChanged = action } func body(content: Content) -> some View { let gesture = SpatialPressingGesture(location: $currentLocation) content .gesture(gesture) .onChange(of: currentLocation, initial: false) { _, location in onPressingChanged(location) } } } struct SpatialPressingGesture: UIGestureRecognizerRepresentable { final class Coordinator: NSObject, UIGestureRecognizerDelegate { @objc func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer ) -> Bool { true } } @Binding var location: CGPoint? func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { Coordinator() } func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { let recognizer = UILongPressGestureRecognizer() recognizer.minimumPressDuration = 0 recognizer.delegate = context.coordinator return recognizer } func handleUIGestureRecognizerAction( _ recognizer: UIGestureRecognizerType, context: Context) { switch recognizer.state { case .began: location = context.converter.localLocation case .ended, .cancelled, .failed: location = nil default: break } } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。