プロパティの変化を監視する:Objective-CランタイムとSwiftネイティブのKVO比較
概要
SwiftのKVO(Key-Value Observing)は、あるオブジェクトのプロパティの変更を監視し、変更が発生したときに通知を受け取るメカニズムです。 SwiftにおけるKVOの実装方法は二つあり、
- Objective-Cランタイムに基づくKVO
- Swift4から可能になったNSKeyValueObservationを使用したKVO
Objective-C ランタイムに基づくKVOについて
最近は見ることが減ってきましたが、 たまに見かけることがあるので備忘録として記載します。
特徴
- プロパティに@objc dynamic修飾子が必要
- Objective-Cランタイムを使用
- SwiftとObjective-Cの互換性を保つために使用されることが多い
実装例
class User: NSObject { @objc dynamic var name: String init(name: String) { self.name = name } } class ViewController: UIViewController { private var user = User(name: "John") private let nameLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textAlignment = .center label.font = UIFont.systemFont(ofSize: 24) return label }() private let changeNameButton: UIButton = { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false button.setTitle("Change Name", for: .normal) return button }() override func viewDidLoad() { super.viewDidLoad() setupViews() setupConstraints() setupObservation() // 初期値を設定 nameLabel.text = user.name } private func setupViews() { view.addSubview(nameLabel) view.addSubview(changeNameButton) changeNameButton.addTarget(self, action: #selector(changeNameTapped), for: .touchUpInside) } private func setupConstraints() { NSLayoutConstraint.activate([ nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), nameLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), changeNameButton.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 20), changeNameButton.centerXAnchor.constraint(equalTo: view.centerXAnchor) ]) } private func setupObservation() { user.addObserver(self, forKeyPath: #keyPath(User.name), options: [.new], context: nil) } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == #keyPath(User.name) { if let newName = change?[.newKey] as? String { nameLabel.text = newName } } } @objc private func changeNameTapped() { let names = ["Alice", "Bob", "Charlie", "Daisy"] user.name = names.randomElement() ?? "Unknown" } deinit { user.removeObserver(self, forKeyPath: #keyPath(User.name)) } }
NSKeyValueObservationを使用したKVOについて
Swift4から追加されたSwiftネイティブのKVOです。 こちらも現在はCombine等に置き換えられてきており、見ることも大分減ってきた気がします。
特徴
- NSKeyValueObservationを使用
- 下記コードのようなクロージャを利用した書き方が出来る。
observation = myObject.observe(\.myProperty, options: [.old, .new]) { object, change in if let newValue = change.newValue { print("myProperty changed to \(newValue)") } }
実装例
class User: NSObject { @objc dynamic var name: String init(name: String) { self.name = name } } class ViewController: UIViewController { private var user = User(name: "John") private var observation: NSKeyValueObservation? private let nameLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textAlignment = .center label.font = UIFont.systemFont(ofSize: 24) return label }() private let changeNameButton: UIButton = { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false button.setTitle("Change Name", for: .normal) return button }() override func viewDidLoad() { super.viewDidLoad() setupViews() setupConstraints() setupObservation() // 初期値を設定 nameLabel.text = user.name } private func setupViews() { view.addSubview(nameLabel) view.addSubview(changeNameButton) changeNameButton.addTarget(self, action: #selector(changeNameTapped), for: .touchUpInside) } private func setupConstraints() { NSLayoutConstraint.activate([ nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), nameLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), changeNameButton.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 20), changeNameButton.centerXAnchor.constraint(equalTo: view.centerXAnchor) ]) } private func setupObservation() { observation = user.observe(\.name, options: [.new]) { [weak self] object, change in guard let self = self, let newName = change.newValue else { return } self.nameLabel.text = newName } } @objc private func changeNameTapped() { let names = ["Alice", "Bob", "Charlie", "Daisy"] user.name = names.randomElement() ?? "Unknown" } deinit { observation?.invalidate() } }
タッチの透過性: iOSのhitTest(_:with:)でインタラクティブUIを設計する
定義
hitTest(_:with:)メソッドは指定されたイベントが発生した、ビュー階層を遍歴し最も遠い子孫のビューオブジェクトを返します。ポイントが現在のビューのビュー階層の完全に外側にある場合はnilを返します。
パラメーター
point: タッチされたポイントをUIViewのローカル座標系で指定します。 event: タッチイベントを含むUIEventオブジェクトです。イベント処理外でメソッドを使用する場合はnilを渡すことができます。
戻り値
タッチされたポイントを含む最前面のサブビューを返します。タッチがどのビューにも当たらない場合はnilを返します。
タッチの透過性について
親ビューのタッチイベントを透過してタッチイベントのインタラクションを子ビューもしくはその孫ビュー等の背後のビューが受け持つという設計が可能になります。
サンプルコード
ヘッダーが半透明の場合にヘッダービューをタッチした場合、 背後のボタンのタッチイベントが始まります。
// // TouchTransparencyViewController.swift // DevRecipe // // Created by 五十嵐伸雄 on 2024/06/25. // import UIKit class TransparentHeaderView: UIView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let view = super.hitTest(point, with: event) return view == self ? nil : view } } class TouchTransparencyViewController: UIViewController, UIScrollViewDelegate { private let headerView = TransparentHeaderView() private let scrollView = UIScrollView() private let stackView = UIStackView() private let headerHeight: CGFloat = 100 private let headerTitleLabel = UILabel() override func viewDidLoad() { super.viewDidLoad() setupScrollView() setupHeaderView() setupStackView() addButtonsToStack() view.backgroundColor = .white } private func setupScrollView() { scrollView.delegate = self scrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scrollView) NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) scrollView.backgroundColor = .white } private func setupHeaderView() { headerView.backgroundColor = UIColor.gray.withAlphaComponent(1) headerView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(headerView) // タイトルラベルを追加 headerTitleLabel.text = "Header Title" headerTitleLabel.textColor = .black headerTitleLabel.font = UIFont.boldSystemFont(ofSize: 20) headerTitleLabel.translatesAutoresizingMaskIntoConstraints = false headerView.addSubview(headerTitleLabel) NSLayoutConstraint.activate([ headerView.heightAnchor.constraint(equalToConstant: headerHeight), headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } private func setupStackView() { stackView.axis = .vertical stackView.distribution = .fillEqually stackView.spacing = 10 stackView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stackView) NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: headerHeight), stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), stackView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) ]) } private func addButtonsToStack() { for i in 0..<20 { let button = UIButton(type: .system) button.setTitle("Button \(i+1)", for: .normal) button.backgroundColor = .systemBlue button.setTitleColor(.white, for: .normal) button.titleLabel?.font = UIFont.systemFont(ofSize: 16) button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) stackView.addArrangedSubview(button) } } @objc private func buttonTapped(sender: UIButton) { print("\(sender.titleLabel?.text ?? "") tapped") } func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y if offset < 0 { // スクロールがヘッダーを越えて上に行く場合、ヘッダーの位置を調整 headerView.frame.origin.y = view.safeAreaInsets.top - offset } else { headerView.frame.origin.y = view.safeAreaInsets.top } // ヘッダーがスタックビューに被った時だけ透明度を調整 let alpha = min(1, max(0, offset / headerHeight)) headerView.backgroundColor = UIColor.gray.withAlphaComponent(1 - alpha * 0.5) } }
hitTestで最前面以外のビューのタッチイベントのみを有効にするようにしています。
カスタムビュー作成:Interface Builder対応と非対応の方法
今更ながらUIViewの
required init?(coder: NSCoder)
について疑問に思ったので、記事にしました。
InterfaceBuilderから初期化する必要がない場合(プログラムからの初期化のみ)
final class CustomView: UIView { // プログラムからの初期化用イニシャライザ override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .red } // Interface Builderからの初期化用イニシャライザ(未実装) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
InterfaceBuilderからのみ初期化
final class CustomView: UIView { // Interface Builderからの初期化用イニシャライザ required init?(coder: NSCoder) { super.init(coder: coder) backgroundColor = .green } // プログラムからの初期化を防ぐためにinit(frame:)を未実装にする override init(frame: CGRect) { fatalError("init(frame:) has not been implemented") } }
InterfaceBuilderからとプログラム両方初期化する必要がある場合
final class CustomView: UIView { // プログラムからの初期化用イニシャライザ override init(frame: CGRect) { super.init(frame: frame) commonInit() } // Interface Builderからの初期化用イニシャライザ required init?(coder: NSCoder) { super.init(coder: coder) commonInit() } // 共通の初期化コード private func commonInit() { backgroundColor = .blue } }
StoryboardではNSCoder付きのinitializerを通して、 XMLがデコードされるようです。 逆にプログラムからの場合はCGRect付きのinitializerを通して、 ビューの位置とサイズを指定します。 このフレーム情報を使ってビューを初期化します。
AutoLayoutのpriorityをコードで書く
コードで書くと
let constraint = topView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true constraint.priority = .defaultHigh // 750
// 優先度1000 static let required: NSLayoutConstraint.Priority // 優先度750 static let defaultHigh: NSLayoutConstraint.Priority // 優先度 250 static let defaultLow: NSLayoutConstraint.Priority
InterfaceBuilderのように数値で入力したい場合は
constraint.priority = .init(rawValue: 333)
とすれば大丈夫です。
human interface guidelinesを読んで
重要そうな箇所を抜粋
レイアウト
- ステータスバーを非表示にするのはアプリの価値や体験が向上する場合のみにする。
- 画面いっぱいのフルサイズ幅のボタンにマージンを持たせる。
マテリアル
- 可読性を確保するためにバイブラントカラー(力強い色)はマテリアルの前面に使用する。
モーション
- モーションはオプションにする。何らかの理由でアプリに動きが表示されないこともあるため、重要な情報をアニメーションだけで伝えることは避ける必要があります。例えば、ユーザが「視差効果を減らす」のアクセシビリティ設定をオンにしている場合は、アニメーションが最小限になるか完全になくなります。
- 頻繁に行われる操作には原則的にアニメーションを追加しない。
プライバシー
- プレアラート画面では表示するボタンは1つだけにし、タップすると次にシステムアラートが表示されることがはっきり分かるようにする。
- カスタムの画面やウインドウに追加のアクションを含めない。
- アプリがリクエストしている機能、データ、リソースがアプリでどのように使用されるのかを明確に記述しておく。
SFSymbols
- SF Symbols 4では、可変色によって、時間の経過と共に変化することがある容量や強さなどの特性をレンダリングモードにかかわりなく表す方法が導入されている。
タイポグラフィ
- 読みやすさを維持するため、通常はフォントのライトウェイトを避ける。例えば、システムで提供されるフォントを使用する場合は、ミディアム、セミボールド、またはボールドのフォントウェイトを優先的に使用し、特にテキストが小さい場合に読みづらくなりがちなウルトラライト、シン、ライトのフォントウェイトを避ける。基本的に、レギュラーの使用は控えめにする。
表現
- ボタンやリンクにラベルを付けるときは、ほとんどの場合、動詞を使用するのが最善です。
- タイトルスタイルまたはセンテンススタイルの大文字化。
- 一人称または二人称の統一
- 「続ける」または「次へ」の統一
共有作業と共有
- 必要に応じて共有シートまたは共有ポップオーバーをカスタマイズし、アプリが対応しているファイル共有方法を選べるようにする。
データの入力
- 可能な限りシステムから情報を入手する。
- フィールドの値を動的に検証する。
- データ入力が必要な場合は、必須データを提供するまで次に進めないことがユーザに分かるようにする。
ファイルの管理
- キャンセルしたり削除したりしない限り、作業内容は常に保存されているとユーザが確信できるようにする。基本的には、ユーザが作業内容を明示的に保存する必要がないようにします。その代わり、ユーザの編集作業中に定期的に自動保存を行い、ユーザがファイルを閉じたり別のアプリに切り替えたりするタイミングでも保存を実行してください。
- ファイル拡張子はデフォルトでは隠し、必要に応じてユーザが表示できるようにする。
起動
- アプリの起動時にプライベートなデータへのアクセス許可を求めることは避け、プライベートなデータを必要とする機能にユーザが興味を示したあとにアクセス許可をリクエストしてください。例えば、ナビアプリであれば位置情報へのアクセスがすぐに必要になると思いますが、ナビ関連の機能が体験の一部にすぎないアプリの場合は、その機能をユーザが使おうとしない限り位置情報は必要ありません。
読み込み
- コンテンツの読み込み中であること、および完了するまでの予想時間を明確に通知する。
アカウントの管理
- アカウントを作成するメリットおよびサインアップ方法について説明する
- ユーザに提供する認証方法を明確に示す。例えば、Face IDでアプリにサインインするためのボタンには、「サインイン」のような汎用的なタイトルではなく、「Face IDでサインイン」のようなタイトルを付けます。
- サインインを要求するタイミングはできるだけ遅らせる。
- iOS、iPadOS、macOS、visionOSのアプリで「Appleでサインイン」を使用していない場合は、パスキーの使用を優先する。
- アカウントの認証でパスコードという用語を使わないようにする。
- ユーザがアカウントの削除をスケジュールできるようにする。
ヘルプ
- 簡潔にする。できる限り、ツールチップのコンテンツは最大でも60字から75字に収めるようにします。
- そのコントロールによって開始されるアクションやタスクを説明する。多くの場合、説明に動詞を含めるとよいでしょう。
オンボーディング
- チュートリアルを提供する場合でもスキップできるようにする。
設定
- できる限り、ユーザが設定領域に移動しなくてもタスク固有のオプションを変更できるようにする。
取り消し
- ユーザが何度も取り消しできるようにする。
- 複数の変更をまとめて元に戻すオプションの提供を検討する。
ボックス
- ボックスは、それを含むビューに対してなるべく小さくする。
- タイトルが必要な場合は、簡潔な表現にする。センテンススタイルの大文字化を使用します(英語の場合)。句読点は使用しないでください。
コレクション
- テキストを表示する場合はコレクションではなくテーブルを使用する。
- デフォルトでは、選択するときはタップし、編集するときはタッチして押さえたままにし、スクロールするときはスワイプします。
アクティブティービュー
- アクティビティビューは「共有」ボタンからのみ表示する。
ボタン
- 基本的にボタンのヒット領域を44x44 pt以上(visionOSの場合は60x60 pt以上)にする必要があります。
- 基本的に、ビューの中で最もよく使われるであろうアクションのボタンには、視認できるバックグラウンドを使う。
- 複数の選択肢の中で推奨するものを視覚的に差別化する場合は、サイズではなくスタイルを使用する。複数の選択肢を提示する際に同じサイズのボタンを使うと、それらが一まとまりの選択肢であることが分かります。推奨される選択肢や最もよく使われる選択肢を強調したい場合は、その選択肢に目立つボタンスタイルを適用し、ほかの選択肢にはそれよりも目立たないスタイルを適用します。
- すぐに完了しないアクションのフィードバックを提供する必要がある場合は、アクティビティインジケータを表示するように設定する。
編集
- できるだけシステムが提供する編集メニューを使う。UIResponderStandardEditActionsを参照する。
- 可能な限り取り消し/やり直しに対応する。ほかのすべてのメニューと同様、編集メニューのアクションを実行する前にユーザの確認は求められないため、直前の状態に戻せる取り消し/やり直しコマンドをユーザが簡単に使えるようにしてください。
メニュー
- 機能を表示する項目と非表示にする項目が多数並ぶことを防ぐために、変化するタイトルの使用を検討する。
- 1対のメニュー項目を表示できるだけのスペースがない場合は、変化するラベルを使用する。
- 切り替え項目が現在有効な属性を表す場合はチェックマークの使用を検討する。
- 複数の切り替え属性を容易に削除できるメニュー項目を提供することを検討する。
- 1つの切り替えのメニュー項目ではなく1対の項目を表示した方が明瞭になる場合は、その表示を検討する。
論理的に関連しているコマンドのグループは、優先度がすべて同じでなくても優先順位付けスキームによって分割しない。例えば、「ペーストしてスタイルを合わせる」の使用頻度は一般に「ペースト」をはるかに下回りますが、ユーザは「コピー」や「カット」などの関連するほかの編集アクションを含む同じグループで両方のコマンドが見つかることを期待します。
スモール。メニューの上部に4つの項目が横1列に並び、その下に残りの項目を含むリストが表示されます。上部の列の各項目にはシンボルまたはアイコンが表示されますが、ラベルはありません。
- ミディアム。メニューの上部に3つの項目が横1列に並び、その下に残りの項目を含むリストが表示されます。上部の列の各項目には、短いラベルの上にシンボルまたはアイコンが表示されます。
- ラージ(デフォルト)。メニューのすべての項目がリストに表示されます。
プルダウン
- 「さらに表示」ボタンはellipsis.circleシンボルを使って作成します。
ツールバー
- コンテンツがツールバーの下に入り込んだときにツールバーが半透明になる点に注意する。
- ツールバーとタブバーは画面の下に表示される点では同じですが、目的は異なります。 ツールバーに含まれるボタンは、項目の作成、項目のフィルタリング、コンテンツのマークアップなど、画面に関係するアクションを実行するために使用します。 タブバーは、例えば時計アプリの「アラーム」、「ストップウォッチ」、「タイマー」タブなど、アプリのさまざまな領域間を移動する際に使います。
- ツールバーとタブバーが同じビューに同時に表示されることはありません。
- ツールバー内でセグメントコントロールを使用しない。
- ツールバーのボタンが3つ以下の場合はシンボルではなく簡潔なテキストラベルを使って意味を明確にすることを検討する。
- ボタンが4つ以上の場合はシンボルを使ってスペースを節約することを検討する。
ナビゲーションバー
- タイトル領域によって有用なコンテキストを提供できる場合は、タイトル領域を使って現在のウインドウを説明する。
- ボタン間に固定スペースの項目を挿入して間隔を広げてください。デベロッパ向けのガイダンスは、UIBarButtonItem.SystemItem.fixedSpaceを参照。
- タイトルとコンテンツのつながり感を強めるため、ラージタイトルのナビゲーションバーの枠線を非表示にすることを検討する。
検索フィールド
- 検索候補を提示して検索操作を改善する。
- 適切なタイミングで検索を開始する。
- 可能な限りタブがあふれないようにする。デバイスのサイズと向きによっては、表示可能なタブの数がタブの合計数よりも少なくなることがあります。横方向の領域の制約によって一部のタブしか表示できない場合は、末尾のタブが「その他」タブになり、残りのタブは別の画面にリストで表示されます。
アクションシート
- ユーザが意図したアクションに関連する選択肢を提示する場合は、アラートではなくアクションシートを使用する。
- タイトルは1行で収まる程度に短くする。
- 破壊的な選択肢は目立つようにする。
- こういったボタンは最も目を引くようにアクションシートの一番上に配置します。
- アクションシートはスクロールさせない。
アラート
- タイトルが完全な文である場合は、センテンススタイルの大文字化(英語の場合)を使用し、適切な文末記号を付けます。断片的な文をタイトルにする場合は、タイトルにふさわしい表現にし、文末記号は付けないようにします。
ページコントロール
- ページコントロールは、順序付きのページリスト内の移動を表す目的で使う。
- インジケータ画像に色を付けない。
- スクラブ中はページ遷移のアニメーションを使用しない。スクラブはかなり速く行われる可能性があります。
- バックグラウンドスタイルを「最小限」にする場合はスクラバーに対応しない。
- デベロッパ向けのガイダンスは、UIPageControl.InteractionStateを参照してください。
スクロールビュー
- スクロールビューの内部に同じ方向の別のスクロールビューを配置しない。
- コンテンツに適合する場合はページ単位のスクロールに対応する。
- 通常は画面ごとに1つのスクロールビューを表示する。
シート
- シートは、シンプルなコンテンツまたはタスクを表示するときに使う。シートを開いても親ビューの一部は見えたままなので、シートには、ユーザが親ビューのコンテキストを念頭に置きながら操作できるという特長があります。
- サイズ変更可能なシートにはグラバーを含める。
- スワイプでシートを閉じられるようにする。
- ユーザの期待に沿って、「完了」ボタンと「キャンセル」ボタンを配置する。
- メインインターフェイスからは一度に1つだけシートを表示する。
ピッカー
- かなり短い選択肢のリストを表示する場合は、ピッカーではなくプルダウンボタンの使用を検討してください。ピッカーの強みは、多くの項目を素早く簡単にスクロールできることです。
- 超大規模な項目を提示する必要がある場合は、リストまたはテーブルの使用を検討してください。リストとテーブルは高さを調整でき、テーブルにはインデックスを設定できます。インデックスによって目的の場所に非常に高速に移動できます。
- 日付ピッカーで分を指定する場合は細かくしすぎないようにする。例えば、1時間を4分割した間隔(0、15、30、45)にできます。
セグメントコントロール
- 密接な関係にあってオブジェクト、状態、表示に影響を与える選択肢でセグメントコントロールを構成する。
- iPhoneで約5個以内に抑えてください。
- セグメントのサイズは基本的に同じにする。
- ツールバー内でセグメントコントロールを使用しないでください。
スライダー
- 対応するテキストフィールドやステッパーでスライダを補完する。特にスライダが広い範囲の値を表す場合は、テキストフィールドでスライダの正確な値を確認したり、特定の値を入力したりできるようにすると効果的です。
テキストフィールド
- テキストフィールドが複数ある場合は均等に間隔を空けて配置する。
- テキストを表示しきれない場合は拡張ツールチップでテキスト全体を見せることを検討する。
- 個々の細かい設定や重要でない設定を制御する目的ではスイッチを使わない。
トグル
スイッチ
チェックボックス
- 設定の階層構造を示す必要がある場合はスイッチではなくチェックボックスを使う。チェックボックスは、位置を揃えたり、グループ構造を伝えたりするのに便利な見た目をしています。例えば、あるチェックボックスの状態によってその従属チェックボックスの状態が決まるような場合は、その依存関係を配置(通常はチェックボックスの先頭側を揃える)とインデントで表現できます。
進行状況インジケータ
- ナビゲーションバーやツールバーでは進行状況バーの塗りつぶされていない部分を表示しない。
通知
- アプリ内で特定のタスクを実行するようにユーザに指示する通知は送信しない。
- 何をすべきかをユーザに指示しないでください。ユーザが通知を閉じたあともそのような指示を覚えていることは困難だからです。
- ユーザの反応がなくても、同じ事項に関して複数の通知を送信しない。
- アプリが前面にあるときはそのアプリの通知は表示されませんが、その場合でもアプリは情報を受信します。こうしたシナリオでは、バッジの番号を増分する、現在のビューで新しいデータを控えめに提供するなど、見つけやすく、かつユーザの気を散らしたり邪魔になったりしない方法で情報を提供します。
- バッジが重要な情報を伝える唯一の方法にならないようにする。
iOS - WebView開いた際の[asset] Failed to get sandbox extensionsについて
重要度の評価
WebViewを開いた際にコンソールで表示される
[asset] Failed to get sandbox extensions
[catalog] Unable to list voice folder
というエラーですが、大多数のケースでアプリケーションの主要な動作には影響を与えない。緊急に対処する必要は通常ありません。しかし、エラーが頻繁に発生する、または特定の操作を阻害している場合は、深刻な問題の兆候である可能性があります。