diff --git a/CHANGELOG.md b/CHANGELOG.md index cb699aa9..ed88ba34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🎉 New features +- 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)) - Add `scrollingExpandsSheet` option to `scrollableOptions`. ([#585](https://github.com/lodev09/react-native-true-sheet/pull/585) by [@lodev09](https://github.com/lodev09)) ### 🐛 Bug fixes diff --git a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt index 774d08a2..181e3ee7 100644 --- a/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +++ b/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt @@ -476,6 +476,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : } } + override fun bottomSheetViewDidAccessibilityIncrement() { + if (currentDetentIndex < detents.size - 1) { + setStateForDetentIndex(currentDetentIndex + 1) + } + } + + override fun bottomSheetViewDidAccessibilityDecrement() { + if (currentDetentIndex > 0) { + setStateForDetentIndex(currentDetentIndex - 1) + } else if (dismissible) { + dismiss(animated = true) + } + } + // ============================================================================= // MARK: - BottomSheetCallback // ============================================================================= @@ -576,6 +590,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : currentDetentIndex = detentInfo.index setupDimmedBackground() delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent) + this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size) } interactionState = InteractionState.Idle @@ -588,6 +603,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : val detent = detentCalculator.getDetentValueForIndex(detentInfo.index) delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent) } + this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size) } } } @@ -776,6 +792,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) : delegate?.viewControllerDidPresent(index, position, detent) parentSheetView?.viewControllerDidBlur() delegate?.viewControllerDidFocus() + sheetView?.updateGrabberAccessibilityValue(index, detents.size) presentPromise?.invoke() presentPromise = null diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt index 918c6309..11f43c21 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt @@ -14,6 +14,7 @@ import android.view.View import android.view.ViewOutlineProvider import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.ThemedReactContext import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -32,6 +33,8 @@ interface TrueSheetBottomSheetViewDelegate { val grabberOptions: GrabberOptions? val draggable: Boolean fun bottomSheetViewDidTapGrabber() + fun bottomSheetViewDidAccessibilityIncrement() + fun bottomSheetViewDidAccessibilityDecrement() } /** @@ -75,6 +78,8 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F // Allow content to extend beyond bounds (for footer positioning) clipChildren = false clipToPadding = false + + ViewCompat.setAccessibilityPaneTitle(this, "Bottom sheet") } override fun setTranslationY(translationY: Float) { @@ -196,11 +201,17 @@ class TrueSheetBottomSheetView(private val reactContext: ThemedReactContext) : F val grabberView = TrueSheetGrabberView(reactContext, delegate?.grabberOptions).apply { tag = GRABBER_TAG + onAccessibilityIncrement = { delegate?.bottomSheetViewDidAccessibilityIncrement() } + onAccessibilityDecrement = { delegate?.bottomSheetViewDidAccessibilityDecrement() } } addView(grabberView) } + fun updateGrabberAccessibilityValue(index: Int, detentCount: Int) { + findViewWithTag(GRABBER_TAG)?.updateAccessibilityValue(index, detentCount) + } + // ============================================================================= // MARK: - Grabber Tap Detection // ============================================================================= diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt index 64621d8e..14ebb91c 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt @@ -29,7 +29,7 @@ interface TrueSheetDimViewDelegate { * This implements the "dimmedDetentIndex" equivalent functionality: * the view only becomes interactive when the sheet is at or above the dimmed detent. */ -@SuppressLint("ViewConstructor", "ClickableViewAccessibility") +@SuppressLint("ViewConstructor") class TrueSheetDimView(private val reactContext: ThemedReactContext) : View(reactContext), ReactPointerEventsView { @@ -60,6 +60,8 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) : setOnClickListener { delegate?.dimViewDidTap() } + + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO } // ============================================================================= diff --git a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt index 570a1513..a53b0963 100644 --- a/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +++ b/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt @@ -5,8 +5,10 @@ import android.content.Context import android.content.res.Configuration import android.graphics.Color import android.graphics.drawable.GradientDrawable +import android.os.Bundle import android.view.Gravity import android.view.View +import android.view.accessibility.AccessibilityNodeInfo import android.widget.FrameLayout import androidx.core.graphics.ColorUtils import com.facebook.react.uimanager.PixelUtil.dpToPx @@ -58,6 +60,9 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions private val grabberColor: Int get() = if (isAdaptive) getAdaptiveColor(options?.color) else options?.color ?: DEFAULT_COLOR + var onAccessibilityIncrement: (() -> Unit)? = null + var onAccessibilityDecrement: (() -> Unit)? = null + init { val hitboxWidth = grabberWidth + (HITBOX_PADDING_HORIZONTAL * 2) val hitboxHeight = grabberHeight + (HITBOX_PADDING_VERTICAL * 2) @@ -86,6 +91,51 @@ class TrueSheetGrabberView(context: Context, private val options: GrabberOptions } addView(pillView) + + isFocusable = true + contentDescription = "Sheet Grabber" + + accessibilityDelegate = object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.addAction( + AccessibilityNodeInfo.AccessibilityAction( + AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, + "Expand" + ) + ) + info.addAction( + AccessibilityNodeInfo.AccessibilityAction( + AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, + "Collapse" + ) + ) + info.className = "android.widget.SeekBar" + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + return when (action) { + AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> { + onAccessibilityIncrement?.invoke() + true + } + AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> { + onAccessibilityDecrement?.invoke() + true + } + else -> super.performAccessibilityAction(host, action, args) + } + } + } + } + + fun updateAccessibilityValue(index: Int, detentCount: Int) { + stateDescription = when { + index < 0 || detentCount <= 0 -> null + index >= detentCount - 1 -> "Expanded" + index == 0 -> "Collapsed" + else -> "Detent ${index + 1} of $detentCount" + } } private fun getAdaptiveColor(baseColor: Int? = null): Int { diff --git a/ios/TrueSheetViewController.mm b/ios/TrueSheetViewController.mm index 342e775d..9f2cc2c5 100644 --- a/ios/TrueSheetViewController.mm +++ b/ios/TrueSheetViewController.mm @@ -236,6 +236,7 @@ - (void)viewDidAppear:(BOOL)animated { [self.delegate viewControllerDidPresentAtIndex:index position:self.currentPosition detent:detent]; [self.delegate viewControllerDidFocus]; + [_grabberView updateAccessibilityValueWithIndex:index detentCount:_detents.count]; [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"did present"]; }); @@ -328,6 +329,7 @@ - (void)viewDidLayoutSubviews { [self learnOffsetForDetentIndex:pendingIndex]; CGFloat detent = [self detentValueForIndex:pendingIndex]; [self.delegate viewControllerDidChangeDetent:pendingIndex position:self.currentPosition detent:detent]; + [self->_grabberView updateAccessibilityValueWithIndex:pendingIndex detentCount:self->_detents.count]; [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"pending detent change"]; }); } @@ -402,7 +404,9 @@ - (void)handlePanGesture:(UIPanGestureRecognizer *)gesture { case UIGestureRecognizerStateCancelled: { if (!_isTransitioning) { dispatch_async(dispatch_get_main_queue(), ^{ - [self learnOffsetForDetentIndex:self.currentDetentIndex]; + NSInteger index = self.currentDetentIndex; + [self learnOffsetForDetentIndex:index]; + [self->_grabberView updateAccessibilityValueWithIndex:index detentCount:self->_detents.count]; [self emitChangePositionDelegateWithPosition:self.currentPosition realtime:NO debug:@"drag end"]; }); } @@ -763,12 +767,37 @@ - (void)setupGrabber { _grabberView.onTap = ^{ [weakSelf handleGrabberTap]; }; + _grabberView.onIncrement = ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + NSInteger current = strongSelf.currentDetentIndex; + NSInteger count = strongSelf->_detents.count; + if (current >= 0 && current < count - 1) { + [strongSelf.sheet animateChanges:^{ + [strongSelf resizeToDetentIndex:current + 1]; + }]; + } + }; + _grabberView.onDecrement = ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + NSInteger current = strongSelf.currentDetentIndex; + if (current > 0) { + [strongSelf.sheet animateChanges:^{ + [strongSelf resizeToDetentIndex:current - 1]; + }]; + } else if (strongSelf.dismissible) { + [strongSelf.presentingViewController dismissViewControllerAnimated:YES completion:nil]; + } + }; [self.view bringSubviewToFront:_grabberView]; } else { self.sheet.prefersGrabberVisible = showGrabber; _grabberView.hidden = YES; _grabberView.onTap = nil; + _grabberView.onIncrement = nil; + _grabberView.onDecrement = nil; } } diff --git a/ios/core/TrueSheetGrabberView.h b/ios/core/TrueSheetGrabberView.h index f3d94c18..cad6afa3 100644 --- a/ios/core/TrueSheetGrabberView.h +++ b/ios/core/TrueSheetGrabberView.h @@ -48,12 +48,21 @@ NS_ASSUME_NONNULL_BEGIN /// Called when the grabber is tapped @property (nonatomic, copy, nullable) void (^onTap)(void); +/// Called when VoiceOver user swipes up (expand) +@property (nonatomic, copy, nullable) void (^onIncrement)(void); + +/// Called when VoiceOver user swipes down (collapse) +@property (nonatomic, copy, nullable) void (^onDecrement)(void); + /// Adds the grabber view to a parent view with proper constraints - (void)addToView:(UIView *)parentView; /// Applies the current configuration to the grabber view - (void)applyConfiguration; +/// Updates the accessibility value based on the current detent position +- (void)updateAccessibilityValueWithIndex:(NSInteger)index detentCount:(NSInteger)count; + @end NS_ASSUME_NONNULL_END diff --git a/ios/core/TrueSheetGrabberView.mm b/ios/core/TrueSheetGrabberView.mm index 598eed1d..cb24e9c1 100644 --- a/ios/core/TrueSheetGrabberView.mm +++ b/ios/core/TrueSheetGrabberView.mm @@ -22,6 +22,8 @@ - (instancetype)init { static const CGFloat kDefaultGrabberWidth = 36.0; static const CGFloat kDefaultGrabberHeight = 5.0; static const CGFloat kDefaultGrabberTopMargin = 5.0; +static const CGFloat kHitPaddingHorizontal = 20.0; +static const CGFloat kHitPaddingVertical = 10.0; @implementation TrueSheetGrabberView { UIVisualEffectView *_vibrancyView; @@ -62,17 +64,19 @@ - (BOOL)isAdaptive { #pragma mark - Setup - (void)setupView { - self.clipsToBounds = YES; + self.clipsToBounds = NO; + self.isAccessibilityElement = YES; + self.accessibilityLabel = @"Sheet Grabber"; + self.accessibilityTraits = UIAccessibilityTraitAdjustable | UIAccessibilityTraitButton; + self.accessibilityHint = @"Double-tap to expand. Swipe up or down to resize the sheet"; UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)]; [self addGestureRecognizer:tap]; _vibrancyView = [[UIVisualEffectView alloc] initWithEffect:nil]; - _vibrancyView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [self addSubview:_vibrancyView]; _fillView = [[UIView alloc] init]; - _fillView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; _fillView.backgroundColor = [UIColor.darkGrayColor colorWithAlphaComponent:0.7]; [_vibrancyView.contentView addSubview:_fillView]; } @@ -85,8 +89,35 @@ - (void)handleTap { } } +- (void)accessibilityIncrement { + if (_onIncrement) { + _onIncrement(); + } +} + +- (void)accessibilityDecrement { + if (_onDecrement) { + _onDecrement(); + } +} + #pragma mark - Public +- (void)updateAccessibilityValueWithIndex:(NSInteger)index detentCount:(NSInteger)count { + if (index < 0 || count <= 0) { + self.accessibilityValue = nil; + return; + } + + if (index >= count - 1) { + self.accessibilityValue = @"Expanded"; + } else if (index == 0) { + self.accessibilityValue = @"Collapsed"; + } else { + self.accessibilityValue = [NSString stringWithFormat:@"Detent %ld of %ld", (long)(index + 1), (long)count]; + } +} + - (void)addToView:(UIView *)parentView { if (self.superview == parentView) { return; @@ -98,17 +129,22 @@ - (void)addToView:(UIView *)parentView { } - (void)applyConfiguration { - CGFloat width = [self effectiveWidth]; - CGFloat height = [self effectiveHeight]; + CGFloat pillWidth = [self effectiveWidth]; + CGFloat pillHeight = [self effectiveHeight]; CGFloat topMargin = [self effectiveTopMargin]; CGFloat parentWidth = self.superview ? self.superview.bounds.size.width : UIScreen.mainScreen.bounds.size.width; - // Position the grabber: centered horizontally, with top margin - self.frame = CGRectMake((parentWidth - width) / 2.0, topMargin, width, height); - self.layer.cornerRadius = [self effectiveCornerRadius]; + CGFloat frameWidth = pillWidth + kHitPaddingHorizontal * 2; + CGFloat frameHeight = pillHeight + kHitPaddingVertical * 2; + CGFloat frameY = topMargin - kHitPaddingVertical; + + self.frame = CGRectMake((parentWidth - frameWidth) / 2.0, frameY, frameWidth, frameHeight); + self.backgroundColor = UIColor.clearColor; - // Update vibrancy and fill view frames - _vibrancyView.frame = self.bounds; + CGRect pillRect = CGRectMake(kHitPaddingHorizontal, kHitPaddingVertical, pillWidth, pillHeight); + _vibrancyView.frame = pillRect; + _vibrancyView.layer.cornerRadius = [self effectiveCornerRadius]; + _vibrancyView.clipsToBounds = YES; _fillView.frame = _vibrancyView.contentView.bounds; if (self.isAdaptive) { @@ -119,9 +155,8 @@ - (void)applyConfiguration { _fillView.hidden = NO; } else { _vibrancyView.effect = nil; - _vibrancyView.backgroundColor = nil; _fillView.hidden = YES; - self.backgroundColor = _color ?: [UIColor.darkGrayColor colorWithAlphaComponent:0.7]; + _vibrancyView.backgroundColor = _color ?: [UIColor.darkGrayColor colorWithAlphaComponent:0.7]; } }