🙌

(SwiftUI)ビューの範囲を.clipped()で切り取ってもVoiceOverのヒットエリアが貫通する場合の対処法

2024/06/16に公開

はじめに

VoiceOverの挙動についての記事です。以下の環境で動作確認しました。

  • Xcode Version 14.2 (14C18)
  • iOS 15.7.1
  • 実機 iPhone 13

※iOS 13以上であれば同じ挙動になるはずですが、すべての環境で動作を保証するものではありません。

意図しない挙動になる実装例

例として、上下2段のビューで構成された画面を考えます。画面下部には細長いタブメニューがあり、上部にはメインのビューがある想定です。アプリの画面レイアウトとしては一般的かと思います。

ここで、メインのビューに巨大なビューを配置します。以下の実装例では縦横が2000 x 2000の青い四角形を配置します。地図アプリなどで大きな画像を配置したものと想像してください。

当然ながら、この巨大なビューは画面に入りません。そこでGeometryReaderでビューを囲むフレームのサイズを取得し、そのサイズに合わせてビューを切り取ります。

import SwiftUI

struct MapView: View {
  let size: CGSize

  var body: some View {
    ZStack {
      Rectangle()
        .frame(width: 2000, height: 2000)
        .foregroundColor(.blue)
    }
    .frame(width: self.size.width, height: self.size.height)
    .clipped()
    .accessibilityAddTraits(.allowsDirectInteraction)
    // .gesture(...) ジェスチャの処理は省略
  }
  init(size: CGSize) {
    self.size = size
  }
}

struct ContentView: View {
  var body: some View {
    VStack {
      HStack {
        GeometryReader { proxy in
          MapView(size: proxy.size)
        }
      }
      HStack {
        Button(action: {
          // 省略
        }) {
          Text("Tab Menu")
            .font(.title)
            .frame(maxWidth: .infinity, maxHeight: 100)
            .background(.black)
            .foregroundColor(.white)
            .accessibilityLabel("Tab Menu")
        }
      }
    }
  }
}

※地図アプリを想定していますがビューをドラッグして見える範囲を移動させる機能は実装を省略しています。

VoiceOverが認識するダイレクトタッチの領域はclippedを無視する

VoiceOverが有効な状態で実装例のアプリを起動して画面下部をタッチします。Buttonビューにフォーカスが当たり「タブメニュー」と読み上げる、これが期待する挙動です。

しかし、実際には画面下部をタッチしても「ダイレクトタッチ領域」と読み上げされます。タブメニューのビューを貫通して画面全体を2000 x 2000のビューが覆っていると、VoiceOverは認識しています。

この挙動は不具合ではありません。Apple developerのドキュメントから引用します。

Use the clipped(antialiased:) modifier to hide any content that extends beyond the layout bounds of the shape. By default, a view’s bounding frame is used only for layout, so any content that extends beyond the edges of the frame is still visible.

引用元 https://developer.apple.com/documentation/swiftui/view/clipped(antialiased:)

フレーム境界をこえた範囲のビューを隠すのが.clipped()の役割です。あくまで外観を整えることしかしません。

見えないビューにフォーカスがあたるのは不具合だと思われるかもしれません。しかし、VoiceOverは画面の外側にある不可視のビューにフォーカスを当てることができます。これは通常の動作であり、VoiceOverユーザーにとって自然な挙動です。

よくある例がカルーセル(水平方向に長いビュー)です。なおカルーセルの場合、正しく実装されていれば不可視の範囲にフォーカスが移動すると、そのビューが画面上に見える(VoiceOverのヒットエリアが現れる)まで自動的にスクロールされます。

従って、上記の実装例は何も対策していないためVoiceOverは.clipped()で切り取られた外側にもヒットエリアが広がっていると認識して、そのように読み上げたのです。

解決策

今回のようなヒットエリア問題を解決するためのModifierが提供されています。.contentShape()です。先ほどの実装例であれば以下のように実装します。

ZStack {
  // ...
}
  .frame(width: self.size.width, height: self.size.height)
  .clipped()
  .contentShape(Rectangle()) // 追加
  .accessibilityAddTraits(.allowsDirectInteraction)

これでビューの外観とVoiceOverが認識するヒットエリアのずれを解消できます。

参考資料

  1. Accessible descriptions - Apple Developer Documentation
  2. Input events - Apple Developer Documentation
  3. clipped(antialiased:) - Apple Developer Documentation
  4. contentShape(_:eoFill:) - Apple Developer Documentation

Discussion