Skip to content

Commit 26e948e

Browse files
authored
feat: improve accessibility on iOS and Android (#587)
* feat: add accessibility roles and labels to native components - Grabber: labeled "Sheet handle" with adjustable role (VoiceOver swipe / TalkBack scroll to resize) - DimView (Android): labeled "Close sheet", hidden from accessibility when invisible - BottomSheetView (Android): pane title announces "Bottom sheet" on appear * fix(android): remove dynamic accessibility for dim view * fix(ios): enlarge grabber hit area to match native sizing * feat: add accessibility value and button trait to grabber view * fix(android): hide dim view from accessibility * feat(android): add labeled accessibility actions to grabber * docs: update changelog for accessibility PR
1 parent c12bfd5 commit 26e948e

8 files changed

Lines changed: 168 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### 🎉 New features
66

7+
- Add accessibility support to grabber view with VoiceOver/TalkBack actions and state descriptions. ([#587](https://github.com/lodev09/react-native-true-sheet/pull/587) by [@lodev09](https://github.com/lodev09))
78
- Add `scrollingExpandsSheet` option to `scrollableOptions`. ([#585](https://github.com/lodev09/react-native-true-sheet/pull/585) by [@lodev09](https://github.com/lodev09))
89

910
### 🐛 Bug fixes

android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
476476
}
477477
}
478478

479+
override fun bottomSheetViewDidAccessibilityIncrement() {
480+
if (currentDetentIndex < detents.size - 1) {
481+
setStateForDetentIndex(currentDetentIndex + 1)
482+
}
483+
}
484+
485+
override fun bottomSheetViewDidAccessibilityDecrement() {
486+
if (currentDetentIndex > 0) {
487+
setStateForDetentIndex(currentDetentIndex - 1)
488+
} else if (dismissible) {
489+
dismiss(animated = true)
490+
}
491+
}
492+
479493
// =============================================================================
480494
// MARK: - BottomSheetCallback
481495
// =============================================================================
@@ -576,6 +590,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
576590
currentDetentIndex = detentInfo.index
577591
setupDimmedBackground()
578592
delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
593+
this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size)
579594
}
580595

581596
interactionState = InteractionState.Idle
@@ -588,6 +603,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
588603
val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
589604
delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
590605
}
606+
this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size)
591607
}
592608
}
593609
}
@@ -776,6 +792,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
776792
delegate?.viewControllerDidPresent(index, position, detent)
777793
parentSheetView?.viewControllerDidBlur()
778794
delegate?.viewControllerDidFocus()
795+
sheetView?.updateGrabberAccessibilityValue(index, detents.size)
779796

780797
presentPromise?.invoke()
781798
presentPromise = null

android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import android.view.View
1414
import android.view.ViewOutlineProvider
1515
import android.widget.FrameLayout
1616
import androidx.coordinatorlayout.widget.CoordinatorLayout
17+
import androidx.core.view.ViewCompat
1718
import com.facebook.react.uimanager.PixelUtil.dpToPx
1819
import com.facebook.react.uimanager.ThemedReactContext
1920
import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -32,6 +33,8 @@ interface TrueSheetBottomSheetViewDelegate {
3233
val grabberOptions: GrabberOptions?
3334
val draggable: Boolean
3435
fun bottomSheetViewDidTapGrabber()
36+
fun bottomSheetViewDidAccessibilityIncrement()
37+
fun bottomSheetViewDidAccessibilityDecrement()
3538
}
3639

3740
/**
@@ -75,6 +78,8 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
7578
// Allow content to extend beyond bounds (for footer positioning)
7679
clipChildren = false
7780
clipToPadding = false
81+
82+
ViewCompat.setAccessibilityPaneTitle(this, "Bottom sheet")
7883
}
7984

8085
override fun setTranslationY(translationY: Float) {
@@ -196,11 +201,17 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F
196201

197202
val grabberView = TrueSheetGrabberView(reactContext, delegate?.grabberOptions).apply {
198203
tag = GRABBER_TAG
204+
onAccessibilityIncrement = { delegate?.bottomSheetViewDidAccessibilityIncrement() }
205+
onAccessibilityDecrement = { delegate?.bottomSheetViewDidAccessibilityDecrement() }
199206
}
200207

201208
addView(grabberView)
202209
}
203210

211+
fun updateGrabberAccessibilityValue(index: Int, detentCount: Int) {
212+
findViewWithTag<TrueSheetGrabberView>(GRABBER_TAG)?.updateAccessibilityValue(index, detentCount)
213+
}
214+
204215
// =============================================================================
205216
// MARK: - Grabber Tap Detection
206217
// =============================================================================

android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ interface TrueSheetDimViewDelegate {
2929
* This implements the "dimmedDetentIndex" equivalent functionality:
3030
* the view only becomes interactive when the sheet is at or above the dimmed detent.
3131
*/
32-
@SuppressLint("ViewConstructor", "ClickableViewAccessibility")
32+
@SuppressLint("ViewConstructor")
3333
class TrueSheetDimView(private val reactContext: ThemedReactContext) :
3434
View(reactContext),
3535
ReactPointerEventsView {
@@ -60,6 +60,8 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) :
6060
setOnClickListener {
6161
delegate?.dimViewDidTap()
6262
}
63+
64+
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
6365
}
6466

6567
// =============================================================================

android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import android.content.Context
55
import android.content.res.Configuration
66
import android.graphics.Color
77
import android.graphics.drawable.GradientDrawable
8+
import android.os.Bundle
89
import android.view.Gravity
910
import android.view.View
11+
import android.view.accessibility.AccessibilityNodeInfo
1012
import android.widget.FrameLayout
1113
import androidx.core.graphics.ColorUtils
1214
import com.facebook.react.uimanager.PixelUtil.dpToPx
@@ -58,6 +60,9 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
5860
private val grabberColor: Int
5961
get() = if (isAdaptive) getAdaptiveColor(options?.color) else options?.color ?: DEFAULT_COLOR
6062

63+
var onAccessibilityIncrement: (() -> Unit)? = null
64+
var onAccessibilityDecrement: (() -> Unit)? = null
65+
6166
init {
6267
val hitboxWidth = grabberWidth + (HITBOX_PADDING_HORIZONTAL * 2)
6368
val hitboxHeight = grabberHeight + (HITBOX_PADDING_VERTICAL * 2)
@@ -86,6 +91,51 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions
8691
}
8792

8893
addView(pillView)
94+
95+
isFocusable = true
96+
contentDescription = "Sheet Grabber"
97+
98+
accessibilityDelegate = object : View.AccessibilityDelegate() {
99+
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
100+
super.onInitializeAccessibilityNodeInfo(host, info)
101+
info.addAction(
102+
AccessibilityNodeInfo.AccessibilityAction(
103+
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD,
104+
"Expand"
105+
)
106+
)
107+
info.addAction(
108+
AccessibilityNodeInfo.AccessibilityAction(
109+
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD,
110+
"Collapse"
111+
)
112+
)
113+
info.className = "android.widget.SeekBar"
114+
}
115+
116+
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean {
117+
return when (action) {
118+
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> {
119+
onAccessibilityIncrement?.invoke()
120+
true
121+
}
122+
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
123+
onAccessibilityDecrement?.invoke()
124+
true
125+
}
126+
else -> super.performAccessibilityAction(host, action, args)
127+
}
128+
}
129+
}
130+
}
131+
132+
fun updateAccessibilityValue(index: Int, detentCount: Int) {
133+
stateDescription = when {
134+
index < 0 || detentCount <= 0 -> null
135+
index >= detentCount - 1 -> "Expanded"
136+
index == 0 -> "Collapsed"
137+
else -> "Detent ${index + 1} of $detentCount"
138+
}
89139
}
90140

91141
private fun getAdaptiveColor(baseColor: Int? = null): Int {

ios/TrueSheetViewController.mm

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ - (void)viewDidAppear:(BOOL)animated {
236236
[self.delegate viewControllerDidPresentAtIndex:index position:self.currentPosition detent:detent];
237237
[self.delegate viewControllerDidFocus];
238238

239+
[_grabberView updateAccessibilityValueWithIndex:index detentCount:_detents.count];
239240
[self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"did present"];
240241
});
241242

@@ -328,6 +329,7 @@ - (void)viewDidLayoutSubviews {
328329
[self learnOffsetForDetentIndex:pendingIndex];
329330
CGFloat detent = [self detentValueForIndex:pendingIndex];
330331
[self.delegate viewControllerDidChangeDetent:pendingIndex position:self.currentPosition detent:detent];
332+
[self->_grabberView updateAccessibilityValueWithIndex:pendingIndex detentCount:self->_detents.count];
331333
[self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"pending detent change"];
332334
});
333335
}
@@ -402,7 +404,9 @@ - (void)handlePanGesture:(UIPanGestureRecognizer *)gesture {
402404
case UIGestureRecognizerStateCancelled: {
403405
if (!_isTransitioning) {
404406
dispatch_async(dispatch_get_main_queue(), ^{
405-
[self learnOffsetForDetentIndex:self.currentDetentIndex];
407+
NSInteger index = self.currentDetentIndex;
408+
[self learnOffsetForDetentIndex:index];
409+
[self->_grabberView updateAccessibilityValueWithIndex:index detentCount:self->_detents.count];
406410
[self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"drag end"];
407411
});
408412
}
@@ -763,12 +767,37 @@ - (void)setupGrabber {
763767
_grabberView.onTap = ^{
764768
[weakSelf handleGrabberTap];
765769
};
770+
_grabberView.onIncrement = ^{
771+
__strong __typeof(weakSelf) strongSelf = weakSelf;
772+
if (!strongSelf) return;
773+
NSInteger current = strongSelf.currentDetentIndex;
774+
NSInteger count = strongSelf->_detents.count;
775+
if (current >= 0 && current < count - 1) {
776+
[strongSelf.sheet animateChanges:^{
777+
[strongSelf resizeToDetentIndex:current + 1];
778+
}];
779+
}
780+
};
781+
_grabberView.onDecrement = ^{
782+
__strong __typeof(weakSelf) strongSelf = weakSelf;
783+
if (!strongSelf) return;
784+
NSInteger current = strongSelf.currentDetentIndex;
785+
if (current > 0) {
786+
[strongSelf.sheet animateChanges:^{
787+
[strongSelf resizeToDetentIndex:current - 1];
788+
}];
789+
} else if (strongSelf.dismissible) {
790+
[strongSelf.presentingViewController dismissViewControllerAnimated:YES completion:nil];
791+
}
792+
};
766793

767794
[self.view bringSubviewToFront:_grabberView];
768795
} else {
769796
self.sheet.prefersGrabberVisible = showGrabber;
770797
_grabberView.hidden = YES;
771798
_grabberView.onTap = nil;
799+
_grabberView.onIncrement = nil;
800+
_grabberView.onDecrement = nil;
772801
}
773802
}
774803

ios/core/TrueSheetGrabberView.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,21 @@ NS_ASSUME_NONNULL_BEGIN
4848
/// Called when the grabber is tapped
4949
@property (nonatomic, copy, nullable) void (^onTap)(void);
5050

51+
/// Called when VoiceOver user swipes up (expand)
52+
@property (nonatomic, copy, nullable) void (^onIncrement)(void);
53+
54+
/// Called when VoiceOver user swipes down (collapse)
55+
@property (nonatomic, copy, nullable) void (^onDecrement)(void);
56+
5157
/// Adds the grabber view to a parent view with proper constraints
5258
- (void)addToView:(UIView *)parentView;
5359

5460
/// Applies the current configuration to the grabber view
5561
- (void)applyConfiguration;
5662

63+
/// Updates the accessibility value based on the current detent position
64+
- (void)updateAccessibilityValueWithIndex:(NSInteger)index detentCount:(NSInteger)count;
65+
5766
@end
5867

5968
NS_ASSUME_NONNULL_END

ios/core/TrueSheetGrabberView.mm

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ - (instancetype)init {
2222
static const CGFloat kDefaultGrabberWidth = 36.0;
2323
static const CGFloat kDefaultGrabberHeight = 5.0;
2424
static const CGFloat kDefaultGrabberTopMargin = 5.0;
25+
static const CGFloat kHitPaddingHorizontal = 20.0;
26+
static const CGFloat kHitPaddingVertical = 10.0;
2527

2628
@implementation TrueSheetGrabberView {
2729
UIVisualEffectView *_vibrancyView;
@@ -62,17 +64,19 @@ - (BOOL)isAdaptive {
6264
#pragma mark - Setup
6365

6466
- (void)setupView {
65-
self.clipsToBounds = YES;
67+
self.clipsToBounds = NO;
68+
self.isAccessibilityElement = YES;
69+
self.accessibilityLabel = @"Sheet Grabber";
70+
self.accessibilityTraits = UIAccessibilityTraitAdjustable | UIAccessibilityTraitButton;
71+
self.accessibilityHint = @"Double-tap to expand. Swipe up or down to resize the sheet";
6672

6773
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)];
6874
[self addGestureRecognizer:tap];
6975

7076
_vibrancyView = [[UIVisualEffectView alloc] initWithEffect:nil];
71-
_vibrancyView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
7277
[self addSubview:_vibrancyView];
7378

7479
_fillView = [[UIView alloc] init];
75-
_fillView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
7680
_fillView.backgroundColor = [UIColor.darkGrayColor colorWithAlphaComponent:0.7];
7781
[_vibrancyView.contentView addSubview:_fillView];
7882
}
@@ -85,8 +89,35 @@ - (void)handleTap {
8589
}
8690
}
8791

92+
- (void)accessibilityIncrement {
93+
if (_onIncrement) {
94+
_onIncrement();
95+
}
96+
}
97+
98+
- (void)accessibilityDecrement {
99+
if (_onDecrement) {
100+
_onDecrement();
101+
}
102+
}
103+
88104
#pragma mark - Public
89105

106+
- (void)updateAccessibilityValueWithIndex:(NSInteger)index detentCount:(NSInteger)count {
107+
if (index < 0 || count <= 0) {
108+
self.accessibilityValue = nil;
109+
return;
110+
}
111+
112+
if (index >= count - 1) {
113+
self.accessibilityValue = @"Expanded";
114+
} else if (index == 0) {
115+
self.accessibilityValue = @"Collapsed";
116+
} else {
117+
self.accessibilityValue = [NSString stringWithFormat:@"Detent %ld of %ld", (long)(index + 1), (long)count];
118+
}
119+
}
120+
90121
- (void)addToView:(UIView *)parentView {
91122
if (self.superview == parentView) {
92123
return;
@@ -98,17 +129,22 @@ - (void)addToView:(UIView *)parentView {
98129
}
99130

100131
- (void)applyConfiguration {
101-
CGFloat width = [self effectiveWidth];
102-
CGFloat height = [self effectiveHeight];
132+
CGFloat pillWidth = [self effectiveWidth];
133+
CGFloat pillHeight = [self effectiveHeight];
103134
CGFloat topMargin = [self effectiveTopMargin];
104135
CGFloat parentWidth = self.superview ? self.superview.bounds.size.width : UIScreen.mainScreen.bounds.size.width;
105136

106-
// Position the grabber: centered horizontally, with top margin
107-
self.frame = CGRectMake((parentWidth - width) / 2.0, topMargin, width, height);
108-
self.layer.cornerRadius = [self effectiveCornerRadius];
137+
CGFloat frameWidth = pillWidth + kHitPaddingHorizontal * 2;
138+
CGFloat frameHeight = pillHeight + kHitPaddingVertical * 2;
139+
CGFloat frameY = topMargin - kHitPaddingVertical;
140+
141+
self.frame = CGRectMake((parentWidth - frameWidth) / 2.0, frameY, frameWidth, frameHeight);
142+
self.backgroundColor = UIColor.clearColor;
109143

110-
// Update vibrancy and fill view frames
111-
_vibrancyView.frame = self.bounds;
144+
CGRect pillRect = CGRectMake(kHitPaddingHorizontal, kHitPaddingVertical, pillWidth, pillHeight);
145+
_vibrancyView.frame = pillRect;
146+
_vibrancyView.layer.cornerRadius = [self effectiveCornerRadius];
147+
_vibrancyView.clipsToBounds = YES;
112148
_fillView.frame = _vibrancyView.contentView.bounds;
113149

114150
if (self.isAdaptive) {
@@ -119,9 +155,8 @@ - (void)applyConfiguration {
119155
_fillView.hidden = NO;
120156
} else {
121157
_vibrancyView.effect = nil;
122-
_vibrancyView.backgroundColor = nil;
123158
_fillView.hidden = YES;
124-
self.backgroundColor = _color ?: [UIColor.darkGrayColor colorWithAlphaComponent:0.7];
159+
_vibrancyView.backgroundColor = _color ?: [UIColor.darkGrayColor colorWithAlphaComponent:0.7];
125160
}
126161
}
127162

0 commit comments

Comments
 (0)