diff --git a/android/build.gradle b/android/build.gradle index 14e992f..741787f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -67,5 +67,8 @@ android { dependencies { implementation "com.facebook.react:react-android" + // ExploreByTouchHelper, used to expose the canvas-drawn scrim as a virtual + // accessibility node. + implementation "androidx.customview:customview:1.1.0" implementation "androidx.dynamicanimation:dynamicanimation:1.0.0" } diff --git a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt index f0a8b77..b351a82 100644 --- a/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt +++ b/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetHostView.kt @@ -4,6 +4,9 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.Rect +import android.os.Bundle +import android.view.KeyEvent import android.view.MotionEvent import android.view.VelocityTracker import android.view.View @@ -12,8 +15,11 @@ import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowInsets import android.widget.FrameLayout +import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.customview.widget.ExploreByTouchHelper import androidx.dynamicanimation.animation.DynamicAnimation import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce @@ -94,6 +100,8 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context) { private val sheetContainer = FrameLayout(context) private val scrimPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val scrimAccessibilityHelper = ScrimAccessibilityHelper() + private var lastAccessibleScrimState = false private var activeAnimation: SpringAnimation? = null private var activeAnimationEmitsSettle = false private var velocityTracker: VelocityTracker? = null @@ -138,6 +146,34 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context) { sheetContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), ) + // The scrim is drawn on this view's canvas, so TalkBack cannot discover it + // as a view; the helper exposes it as a virtual dismiss button instead. + ViewCompat.setAccessibilityDelegate(this, scrimAccessibilityHelper) + // TalkBack's dismiss gesture resolves ACTION_DISMISS against the focused + // node's ancestors, so the action lives on the container that hosts the + // sheet content — the equivalent of iOS's accessibilityPerformEscape. + ViewCompat.setAccessibilityDelegate( + sheetContainer, + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + if (isScrimAccessible()) { + info.isDismissable = true + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS) + } + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && attemptScrimDismissal()) { + return true + } + return super.performAccessibilityAction(host, action, args) + } + }, + ) } val sheetChildCount: Int @@ -297,6 +333,78 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context) { super.dispatchDraw(canvas) } + // MARK: - Accessibility + // + // ExploreByTouchHelper drives the scrim's virtual node from hover (touch + // exploration), key, and focus events, so all three streams are forwarded. + + override fun dispatchHoverEvent(event: MotionEvent): Boolean = + scrimAccessibilityHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event) + + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + scrimAccessibilityHelper.dispatchKeyEvent(event) || super.dispatchKeyEvent(event) + + override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + scrimAccessibilityHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + } + + private fun sheetTopEdge(): Float = sheetContainer.top + sheetContainer.translationY + + // The scrim participates in accessibility only while activating it would + // dismiss the sheet; a scrim over a programmatic-only close detent is + // decorative, not actionable. + private fun isScrimAccessible(): Boolean = + isScrimVisible() && scrimDismissIndex != null && !isTargetingClosedDetent + + /** + * Dismisses a modal sheet to its closed detent through the exact path a scrim tap takes, + * returning whether a dismissal was actually performed. + */ + private fun attemptScrimDismissal(): Boolean { + val closeIndex = scrimDismissIndex ?: return false + if (!isScrimVisible() || targetIndex == closeIndex) return false + snapToIndex(closeIndex, 0f) + return true + } + + private inner class ScrimAccessibilityHelper : ExploreByTouchHelper(this@BottomSheetHostView) { + + override fun getVirtualViewAt(x: Float, y: Float): Int = + if (isScrimAccessible() && y < sheetTopEdge()) SCRIM_VIRTUAL_VIEW_ID else INVALID_ID + + override fun getVisibleVirtualViews(virtualViewIds: MutableList) { + if (isScrimAccessible()) virtualViewIds.add(SCRIM_VIRTUAL_VIEW_ID) + } + + override fun onPopulateNodeForVirtualView( + virtualViewId: Int, + node: AccessibilityNodeInfoCompat, + ) { + node.className = "android.widget.Button" + node.contentDescription = context.getString(R.string.bottom_sheet_dismiss) + node.isClickable = true + node.isDismissable = true + node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK) + node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS) + // The helper requires non-empty bounds; the scrim's actionable region + // spans from the host's top edge down to the sheet's current top. + val bottom = sheetTopEdge().toInt().coerceIn(1, height.coerceAtLeast(1)) + node.setBoundsInParent(Rect(0, 0, width.coerceAtLeast(1), bottom)) + } + + override fun onPerformActionForVirtualView( + virtualViewId: Int, + action: Int, + arguments: Bundle?, + ): Boolean = + when (action) { + AccessibilityNodeInfoCompat.ACTION_CLICK, + AccessibilityNodeInfoCompat.ACTION_DISMISS -> attemptScrimDismissal() + else -> false + } + } + private fun layoutSheetChildren(containerWidth: Int, containerHeight: Int) { for (i in 0 until sheetContainer.childCount) { val child = sheetContainer.getChildAt(i) @@ -1273,6 +1381,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context) { scrimTouchActive = false scrimProgress = 0f suppressScrimForClosingTarget = false + lastAccessibleScrimState = false sheetContainer.removeAllViews() stateWrapper = null lastShadowOffsetY = Float.NaN @@ -1360,6 +1469,13 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context) { val interactive = modal && (activeAnimation != null || isPanning || isScrimVisible()) pointerEvents = if (interactive) PointerEvents.AUTO else PointerEvents.BOX_NONE interactionListener?.invoke(interactive) + val accessibleScrim = isScrimAccessible() + if (accessibleScrim != lastAccessibleScrimState) { + lastAccessibleScrimState = accessibleScrim + // The scrim's virtual node appeared or disappeared; only transitions are + // reported — this method runs on every frame of a settle. + scrimAccessibilityHelper.invalidateRoot() + } } private fun currentSheetHeight(): Float { @@ -1392,5 +1508,7 @@ class BottomSheetHostView(context: Context) : ReactViewGroup(context) { // observing to end the redraw loop, while the marker layout listener can // still complete the snap if the content becomes measurable later. private const val MAX_PENDING_INITIAL_CONTENT_DETENT_FRAMES = 240 + + private const val SCRIM_VIRTUAL_VIEW_ID = 1 } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..fc49745 --- /dev/null +++ b/android/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + Dismiss + diff --git a/ios/BottomSheetHostingView.swift b/ios/BottomSheetHostingView.swift index 29eb461..21f8102 100644 --- a/ios/BottomSheetHostingView.swift +++ b/ios/BottomSheetHostingView.swift @@ -37,6 +37,17 @@ private struct PendingSnapRequest { let preserveScrimPin: Bool } +/// The scrim control, exposed to VoiceOver as a dismiss button while a +/// dismissible modal sheet is open. VoiceOver's default activation simulates +/// a tap at the activation point; sending the control action directly keeps +/// activation reliable even when the sheet overlaps that point mid-settle. +private final class BottomSheetScrimControl: UIControl { + override func accessibilityActivate() -> Bool { + sendActions(for: .touchUpInside) + return true + } +} + @objcMembers public final class BottomSheetHostingView: UIView { public weak var eventDelegate: BottomSheetHostingViewDelegate? @@ -91,7 +102,7 @@ public final class BottomSheetHostingView: UIView { public var animateContentHeight: Bool = true public let sheetContainer = UIView() - private let scrimView = UIControl() + private let scrimView = BottomSheetScrimControl() private var panGesture: UIPanGestureRecognizer! private var activeSpring: CriticalSpring? private var activeSpringTargetIndex: Int = 0 @@ -121,6 +132,9 @@ public final class BottomSheetHostingView: UIView { scrimView.alpha = 0 scrimView.isHidden = true scrimView.addTarget(self, action: #selector(handleScrimPress), for: .touchUpInside) + scrimView.isAccessibilityElement = false + scrimView.accessibilityTraits = .button + scrimView.accessibilityLabel = "Dismiss" addSubview(scrimView) sheetContainer.backgroundColor = .clear @@ -524,16 +538,31 @@ public final class BottomSheetHostingView: UIView { } @objc private func handleScrimPress() { + attemptScrimDismissal() + } + + /// Dismisses a modal sheet to its closed detent through the exact path a + /// scrim tap takes, returning whether a dismissal was actually performed. + @discardableResult + private func attemptScrimDismissal() -> Bool { guard modal, let closedIndex = scrimDismissIndex, targetIndex != closedIndex, activeSpring == nil || currentSheetHeight > 0.5 else { - return + return false } snapToIndex(closedIndex, velocity: 0) + return true + } + + /// VoiceOver's escape gesture (two-finger Z scrub) dismisses a modal sheet, + /// mirroring a scrim tap. Returning false when there is nothing to dismiss + /// lets the gesture keep bubbling to enclosing containers. + override public func accessibilityPerformEscape() -> Bool { + attemptScrimDismissal() } private func snapToIndex( @@ -1317,5 +1346,10 @@ private extension BottomSheetHostingView { func updateInteractionState() { scrimView.isUserInteractionEnabled = modal && (closedIndex != nil) && !scrimView.isHidden + // Expose the scrim to VoiceOver only while tapping it would dismiss the + // sheet; otherwise it would be an inert, unlabeled stop in the + // accessibility tree (a scrim over a programmatic-only close detent is + // decorative, not actionable). + scrimView.isAccessibilityElement = modal && scrimDismissIndex != nil && !scrimView.isHidden } }