Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Int>) {
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
6 changes: 6 additions & 0 deletions android/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Announced by TalkBack for the scrim's virtual accessibility node while a
dismissible modal sheet is open. -->
<string name="bottom_sheet_dismiss">Dismiss</string>
</resources>
38 changes: 36 additions & 2 deletions ios/BottomSheetHostingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
}