Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,8 @@ interface TrueSheetBottomSheetViewDelegate {
val grabberOptions: GrabberOptions?
val draggable: Boolean
fun bottomSheetViewDidTapGrabber()
fun bottomSheetViewDidAccessibilityIncrement()
fun bottomSheetViewDidAccessibilityDecrement()
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<TrueSheetGrabberView>(GRABBER_TAG)?.updateAccessibilityValue(index, detentCount)
}

// =============================================================================
// MARK: - Grabber Tap Detection
// =============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -60,6 +60,8 @@ class TrueSheetDimView(private val reactContext: ThemedReactContext) :
setOnClickListener {
delegate?.dimViewDidTap()
}

importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
}

// =============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 30 additions & 1 deletion ios/TrueSheetViewController.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
});

Expand Down Expand Up @@ -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"];
});
}
Expand Down Expand Up @@ -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"];
});
}
Expand Down Expand Up @@ -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;
}
}

Expand Down
9 changes: 9 additions & 0 deletions ios/core/TrueSheetGrabberView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 47 additions & 12 deletions ios/core/TrueSheetGrabberView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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];
}
}

Expand Down