Skip to content

Commit e45a918

Browse files
authored
feat(ios): add scroll edge effect support for iOS 26+ (#595)
* feat(ios): add scroll edge effect support for iOS 26+ Add topScrollEdgeEffect and bottomScrollEdgeEffect to scrollableOptions for UIScrollEdgeEffect style control. * docs: update changelog for scroll edge effect * feat(ios): add scroll edge element container interaction for header/footer Use UIScrollEdgeElementContainerInteraction on iOS 26+ to apply automatic blur/gradient edge effects on header and footer views when content scrolls behind them. * feat(ios): default scroll edge effect to hidden Also update docs with scroll edge effect guide and type reference. * fix(ios): fix hasScrollableOptions check and deduplicate edge interaction cleanup * refactor(ios): extract edge interaction into UIView category and fix review issues - Add @available guard for setupEdgeInteractions in setupScrollable - Make scrollView param nullable in edge interaction methods - Extract shared edge interaction logic into UIView+ScrollEdgeInteraction - Remove redundant setupEdgeInteractions calls from mount/scrollViewDidChange * chore: run tidy
1 parent c2754ea commit e45a918

20 files changed

Lines changed: 312 additions & 29 deletions

CHANGELOG.md

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

77
- 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))
88
- Add `scrollingExpandsSheet` option to `scrollableOptions`. ([#585](https://github.com/lodev09/react-native-true-sheet/pull/585) by [@lodev09](https://github.com/lodev09))
9+
- **iOS**: Add `topScrollEdgeEffect` and `bottomScrollEdgeEffect` to `scrollableOptions` for iOS 26+. ([#595](https://github.com/lodev09/react-native-true-sheet/pull/595) by [@lodev09](https://github.com/lodev09))
910

1011
### 🐛 Bug fixes
1112

docs/docs/guides/scrolling.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,38 @@ By default, scrolling content to the top edge expands the sheet to the next dete
8080

8181
On iOS, this uses [`prefersScrollingExpandsWhenScrolledToEdge`](https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller/3858107-prefersscrollingexpandswhenscrol). On Android, a custom `BottomSheetBehavior` selectively blocks scroll-driven expansion while preserving normal scrolling, fling deceleration, and grabber-based dragging.
8282

83+
## Scroll Edge Effect
84+
85+
On iOS 26+, you can apply a blur/gradient edge effect on the header and footer when content scrolls behind them. This uses [`UIScrollEdgeElementContainerInteraction`](https://developer.apple.com/documentation/uikit/uiscrolledgeelementcontainerinteraction) under the hood.
86+
87+
Set [`topScrollEdgeEffect`](../reference/types#scrollableoptions) and/or [`bottomScrollEdgeEffect`](../reference/types#scrollableoptions) in [`scrollableOptions`](../reference/configuration#scrollableoptions) to enable it:
88+
89+
```tsx
90+
<TrueSheet
91+
scrollable
92+
scrollableOptions={{
93+
topScrollEdgeEffect: 'automatic',
94+
bottomScrollEdgeEffect: 'soft',
95+
}}
96+
header={<Header />}
97+
footer={<Footer />}
98+
>
99+
<ScrollView>
100+
<View />
101+
</ScrollView>
102+
</TrueSheet>
103+
```
104+
105+
Available values: `'automatic'`, `'hard'`, `'soft'`, or `'hidden'` (default). See [`ScrollEdgeEffect`](../reference/types#scrolledgeeffect).
106+
107+
:::info
108+
This also controls the scroll view's own edge effects ([`UIScrollEdgeEffect`](https://developer.apple.com/documentation/uikit/uiscrolledgeeffect)). `topScrollEdgeEffect` applies to both the header interaction and the scroll view's top edge, and `bottomScrollEdgeEffect` applies to both the footer interaction and the scroll view's bottom edge.
109+
:::
110+
111+
:::note
112+
This feature requires iOS 26 or later. On older versions, these options are ignored.
113+
:::
114+
83115
## Using `scrollToEnd`
84116

85117
When using `scrollable`, the native scroll view frame is modified to fit within the sheet container. This can cause `scrollToEnd()` on `FlatList` or `ScrollView` to not work as expected because React Native's JS-side scroll metrics may not reflect the actual native dimensions.

docs/docs/reference/04-types.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ Options for customizing scrollable behavior. Only applies when `scrollable` is `
9797
| - | - | - | - |
9898
| `scrollingExpandsSheet` | `boolean` | When `false`, scrolling the content does not expand the sheet to the next detent. Only dragging the grabber or sheet background expands it. Useful for YouTube-style comments sheets. | `true` |
9999
| `keyboardScrollOffset` | `number` | Additional offset when scrolling to a focused input above the keyboard. | `0` |
100+
| `topScrollEdgeEffect` | [`ScrollEdgeEffect`](#scrolledgeeffect) | The scroll edge effect applied to the top edge of the scroll view and header. iOS 26+ only. | `'hidden'` |
101+
| `bottomScrollEdgeEffect` | [`ScrollEdgeEffect`](#scrolledgeeffect) | The scroll edge effect applied to the bottom edge of the scroll view and footer. iOS 26+ only. | `'hidden'` |
102+
103+
## `ScrollEdgeEffect`
104+
105+
Controls the blur/gradient edge effect on the scroll view edges and header/footer overlay views. iOS 26+ only.
106+
107+
| Value | Description |
108+
| - | - |
109+
| `'automatic'` | System default edge effect style. |
110+
| `'hard'` | A hard, opaque edge effect. |
111+
| `'soft'` | A soft, gradient edge effect. |
112+
| `'hidden'` | No edge effect. This is the default. |
100113

101114
## `GrabberOptions`
102115

example/bare/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2649,7 +2649,7 @@ PODS:
26492649
- ReactCommon/turbomodule/core
26502650
- SocketRocket
26512651
- Yoga
2652-
- RNTrueSheet (3.9.9):
2652+
- RNTrueSheet (3.10.0-beta.1):
26532653
- boost
26542654
- DoubleConversion
26552655
- fast_float
@@ -3104,7 +3104,7 @@ SPEC CHECKSUMS:
31043104
RNGestureHandler: e1cf8ef3f11045536eed6bd4f132b003ef5f9a5f
31053105
RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee
31063106
RNScreens: d821082c6dd1cb397cc0c98b026eeafaa68be479
3107-
RNTrueSheet: a3094081efc659586c568292d1b778703611a5dd
3107+
RNTrueSheet: cda20577e6b3553643620281ee9143f0ae6c8371
31083108
RNWorklets: d9c050940f140af5d8b611d937eab1cbfce5e9a5
31093109
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
31103110
Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb

example/shared/src/components/Footer.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,20 @@ export const Footer = ({ children, text = 'FOOTER', onPress, ...rest }: FooterPr
2828

2929
const styles = StyleSheet.create({
3030
wrapper: {
31-
backgroundColor: DARK_GRAY,
31+
backgroundColor: Platform.select({
32+
default: DARK_GRAY,
33+
ios: undefined,
34+
}),
3235
},
3336
container: {
3437
height: FOOTER_HEIGHT,
35-
padding: SPACING,
38+
paddingHorizontal: SPACING,
3639
},
3740
pressed: {
3841
opacity: 0.6,
3942
},
4043
text: {
44+
marginTop: SPACING,
4145
textAlign: 'center',
4246
color: '#fff',
4347
},

example/shared/src/components/Input.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StyleSheet, TextInput, View, type TextInputProps } from 'react-native';
1+
import { Platform, StyleSheet, TextInput, View, type TextInputProps } from 'react-native';
22

33
import { LIGHT_GRAY, INPUT_HEIGHT, SPACING } from '../utils';
44
import { forwardRef } from 'react';
@@ -25,7 +25,10 @@ export const Input = forwardRef<TextInput, TextInputProps>((props, ref) => {
2525

2626
const styles = StyleSheet.create({
2727
inputContainer: {
28-
backgroundColor: 'rgba(0, 0, 0, 0.3)',
28+
backgroundColor: Platform.select({
29+
ios: 'rgba(0, 0, 0, 0.5)',
30+
default: 'rgba(0, 0, 0, 0.3)',
31+
}),
2932
paddingHorizontal: SPACING,
3033
height: INPUT_HEIGHT,
3134
borderRadius: INPUT_HEIGHT,

example/shared/src/components/sheets/FlatListSheet.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { forwardRef, useRef } from 'react';
22
import { StyleSheet, FlatList, View } from 'react-native';
33
import { TrueSheet, type TrueSheetProps } from '@lodev09/react-native-true-sheet';
44

5-
import { DARK, DARK_GRAY, FOOTER_HEIGHT, SPACING, times } from '../../utils';
6-
import { Input } from '../Input';
5+
import { DARK, DARK_GRAY, FOOTER_HEIGHT, HEADER_HEIGHT, SPACING, times } from '../../utils';
76
import { DemoContent } from '../DemoContent';
87
import { Spacer } from '../Spacer';
98
import { Header } from '../Header';
@@ -22,11 +21,11 @@ export const FlatListSheet = forwardRef<TrueSheet, FlatListSheetProps>((props, r
2221
backgroundBlur="dark"
2322
backgroundColor={DARK}
2423
scrollable
25-
header={
26-
<Header>
27-
<Input />
28-
</Header>
29-
}
24+
scrollableOptions={{
25+
bottomScrollEdgeEffect: 'soft',
26+
topScrollEdgeEffect: 'soft',
27+
}}
28+
header={<Header style={styles.header} />}
3029
onDidDismiss={() => console.log('Sheet FlatList dismissed!')}
3130
onDidPresent={() => console.log(`Sheet FlatList presented!`)}
3231
footer={<Footer text="OPEN BLANK SHEET" onPress={() => testRef.current?.present()} />}
@@ -56,8 +55,15 @@ const styles = StyleSheet.create({
5655
wrapper: {
5756
flex: 1,
5857
},
58+
header: {
59+
position: 'absolute',
60+
left: 0,
61+
right: 0,
62+
zIndex: 1,
63+
},
5964
content: {
6065
padding: SPACING,
66+
paddingTop: HEADER_HEIGHT,
6167
paddingBottom: FOOTER_HEIGHT + SPACING,
6268
},
6369
});

example/shared/src/components/sheets/ScrollViewSheet.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,19 @@ import {
1111
} from 'react-native';
1212
import { TrueSheet, type TrueSheetProps } from '@lodev09/react-native-true-sheet';
1313

14-
import { BORDER_RADIUS, DARK, FOOTER_HEIGHT, GAP, LIGHT_GRAY, SPACING, times } from '../../utils';
14+
import {
15+
BORDER_RADIUS,
16+
DARK,
17+
FOOTER_HEIGHT,
18+
GAP,
19+
HEADER_HEIGHT,
20+
LIGHT_GRAY,
21+
SPACING,
22+
times,
23+
} from '../../utils';
1524
import { Footer } from '../Footer';
1625
import { Header } from '../Header';
26+
import { Button } from '../Button';
1727

1828
interface ScrollViewSheetProps extends TrueSheetProps {}
1929

@@ -55,10 +65,19 @@ export const ScrollViewSheet = forwardRef<TrueSheet, ScrollViewSheetProps>((prop
5565
detents={[0.8, 1]}
5666
name="scrollview"
5767
scrollable
58-
scrollableOptions={{ scrollingExpandsSheet: false }}
68+
scrollableOptions={{
69+
scrollingExpandsSheet: false,
70+
bottomScrollEdgeEffect: 'soft',
71+
topScrollEdgeEffect: 'soft',
72+
}}
5973
backgroundColor={Platform.select({ android: DARK })}
6074
header={<Header />}
61-
footer={<Footer text="TOGGLE LISTVIEW" onPress={() => setShowList(!showList)} />}
75+
headerStyle={styles.header}
76+
footer={
77+
<Footer>
78+
<Button text="Toggle ListView" onPress={() => setShowList(!showList)} />
79+
</Footer>
80+
}
6281
onDidDismiss={() => console.log('Sheet ScrollView dismissed!')}
6382
onDidPresent={() => console.log(`Sheet ScrollView presented!`)}
6483
{...props}
@@ -89,9 +108,16 @@ ScrollViewSheet.displayName = 'ScrollViewSheet';
89108
const styles = StyleSheet.create({
90109
content: {
91110
padding: SPACING,
111+
paddingTop: HEADER_HEIGHT,
92112
paddingBottom: FOOTER_HEIGHT + SPACING,
93113
gap: GAP,
94114
},
115+
header: {
116+
position: 'absolute',
117+
left: 0,
118+
right: 0,
119+
zIndex: 1,
120+
},
95121
item: {
96122
backgroundColor: 'rgba(0, 0, 0, 0.3)',
97123
borderRadius: BORDER_RADIUS,

ios/TrueSheetContainerView.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ NS_ASSUME_NONNULL_BEGIN
1717

1818
@property (nonatomic, assign) CGFloat keyboardScrollOffset;
1919
@property (nonatomic, assign) BOOL scrollingExpandsSheet;
20+
@property (nonatomic, assign) NSInteger topScrollEdgeEffect;
21+
@property (nonatomic, assign) NSInteger bottomScrollEdgeEffect;
2022

2123
@end
2224

ios/TrueSheetContainerView.mm

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
#ifdef RCT_NEW_ARCH_ENABLED
1010

1111
#import "TrueSheetContainerView.h"
12+
#import <React/RCTScrollViewComponentView.h>
1213
#import "TrueSheetContentView.h"
1314
#import "TrueSheetFooterView.h"
1415
#import "TrueSheetHeaderView.h"
1516
#import "TrueSheetViewController.h"
1617
#import "core/TrueSheetKeyboardObserver.h"
18+
#import "utils/UIView+ScrollEdgeInteraction.h"
1719
#import "utils/WindowUtil.h"
1820

1921
#import <react/renderer/components/TrueSheetSpec/ComponentDescriptors.h>
@@ -33,6 +35,8 @@ - (instancetype)init {
3335
if (self = [super init]) {
3436
_keyboardScrollOffset = 0;
3537
_scrollingExpandsSheet = YES;
38+
_topScrollEdgeEffect = (NSInteger)TrueSheetViewTopScrollEdgeEffect::Hidden;
39+
_bottomScrollEdgeEffect = (NSInteger)TrueSheetViewBottomScrollEdgeEffect::Hidden;
3640
}
3741
return self;
3842
}
@@ -111,6 +115,34 @@ - (void)setupScrollable {
111115
bottomInset = [WindowUtil keyWindow].safeAreaInsets.bottom;
112116
}
113117
[_contentView setupScrollable:_scrollableEnabled bottomInset:bottomInset];
118+
[_contentView applyScrollEdgeEffects:_scrollableOptions];
119+
if (@available(iOS 26.0, *)) {
120+
[self setupEdgeInteractions];
121+
}
122+
}
123+
}
124+
125+
- (void)setupEdgeInteractions API_AVAILABLE(ios(26.0)) {
126+
if (!_contentView) {
127+
return;
128+
}
129+
130+
NSInteger topEffect =
131+
_scrollableOptions ? _scrollableOptions.topScrollEdgeEffect : (NSInteger)TrueSheetViewTopScrollEdgeEffect::Hidden;
132+
NSInteger bottomEffect = _scrollableOptions ? _scrollableOptions.bottomScrollEdgeEffect
133+
: (NSInteger)TrueSheetViewBottomScrollEdgeEffect::Hidden;
134+
135+
BOOL topHidden = topEffect == (NSInteger)TrueSheetViewTopScrollEdgeEffect::Hidden;
136+
BOOL bottomHidden = bottomEffect == (NSInteger)TrueSheetViewBottomScrollEdgeEffect::Hidden;
137+
138+
RCTScrollViewComponentView *scrollViewComponent = [_contentView findScrollView];
139+
UIScrollView *scrollView = scrollViewComponent.scrollView;
140+
141+
if (_headerView) {
142+
[_headerView setupEdgeInteractionWithScrollView:topHidden ? nil : scrollView edge:UIRectEdgeTop];
143+
}
144+
if (_footerView) {
145+
[_footerView setupEdgeInteractionWithScrollView:bottomHidden ? nil : scrollView edge:UIRectEdgeBottom];
114146
}
115147
}
116148

0 commit comments

Comments
 (0)