Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(iOS): support for custom detents with formSheet presentation #1852

Closed
wants to merge 74 commits into from

Conversation

kkafar
Copy link
Member

@kkafar kkafar commented Aug 1, 2023

Description

This PR adds support for custom detents with formSheet modal presentation on iOS >= 16.0.

This new feature can be used with native-stack v5 (react-native-screens/native-stack) via sheetAllowedDetents prop by passing an array of values to it (see prop description in docs).

I plan on exposing it in v6 (@react-navigation/native-stack) in react-navigation/react-navigation#11516 or in a follow up PR.

Note: I'm looking for any suggestions on better prop naming :D

See #1870 for view flickering issue description & solution

Resolves #1772

Changes

  • Implemented native logic
  • Exposed the prop from native side
  • Exposed the prop and set default values in JS
  • Added Android stubs
  • Updated docs

Implementation notes

Please look below for my review.

Known issues

View flickering on Paper and Fabric (the exact cause is not determined yet, but the code responsible for this is in updateBounds method which is called on every layout recalculation and it reports current view size to react (/ updates shadow node).

See:

Test code and steps to reproduce

Test1649 in both TestsExample and FabricTestExample apps.

Checklist

@kkafar kkafar changed the title feat(iOS): support for custom detens on formSheet presentation feat(iOS): support for custom detents on formSheet presentation Aug 1, 2023
@kkafar kkafar changed the title feat(iOS): support for custom detents on formSheet presentation feat(iOS): support for custom detents with formSheet presentation Aug 2, 2023
@ferrannp
Copy link
Contributor

ferrannp commented Aug 2, 2023

Do you want some help to test this @kkafar ? Also, if you make this native for Android... you will be a hero 😅.

@kkafar
Copy link
Member Author

kkafar commented Aug 3, 2023

Hey @ferrannp,
thank you and yes -- I would really use a hand in testing this stuff, any feedback is welcome.

Currently I'm working on Fabric implementation which has some intricacies and it is not ready yet.
Paper, however should work just fine (but I have not tested how this behaves on iPad yet).

Also I'm still thinking on prop naming and best way of exposing this functionality, any suggestions would also be welcome. At the moment I'm using dedicated prop temporarily named sheetCustomDetents which has effect only if:

  1. stackPresentation == 'formSheet'
  2. sheetAllowedDetents == 'custom'

But I also consider accepting an array with sheetAllowedDetents prop (so the declaration would be sheetAllowedDetents: SheetDetentTypes | number[] and dispatching this internally.

@ferrannp
Copy link
Contributor

ferrannp commented Aug 4, 2023

I will test it then @kkafar 👍 . For me this one sheetAllowedDetents: SheetDetentTypes | number[] makes more sense 👍 . Is there a limit of amount of detents do you know? Cause if there is a limit maybe we can be more explicit in the type number[].

@kkafar
Copy link
Member Author

kkafar commented Aug 8, 2023

I'm not aware of any limitation on number of detents.
Only restriction I'm aware of right now is that the array has to be sorted in ascending order.

Copy link
Member Author

@kkafar kkafar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General remarks:

  1. Prop definitions differ for native stack v5 and native component ScreenProps.

In native stack we have:

sheetAllowedDetents: SheetDetentTypes | number[],
sheetLargestUndimmedDetent: SheetDetentTypes | number,

while native component goes with four distinct props:

sheetAllowedDetents: SheetDetentTypes,
sheetLargestUndimmedDetent: SheetDetentTypes,
sheetCustomDetents: number[],
sheetCustomLargestUndimmedDetent: number,

It made native implementation much easier. Alternatively we could take an array of strings:

sheetAllowedDetents: (SheetDetentTypes | string)[],
sheetLargestUndimmedDetent: SheetDetentTypes | string,

where string is a percentage, e.g. '50%', and parse it on native side?

Looking for any suggestions

  1. Either native detent levels ('medium', 'large', 'all') or custom ones can be used, but not both at the same time.

This is because the values I receive must be sorted & exact height of native detents is not known and it is context dependent, thus I reject mixing them.

@@ -35,6 +35,7 @@
"babel-jest": "^29.2.1",
"eslint": "^8.19.0",
"jest": "^29.2.1",
"jotai": "^2.2.3",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it to both TestsExample & FabricTestExample to make my life much easier (it is a simple state management library, 399kB unpacked).

@@ -85,9 +85,11 @@ namespace react = facebook::react;
// Props controlling UISheetPresentationController
@property (nonatomic) RNSScreenDetentType sheetAllowedDetents;
@property (nonatomic) RNSScreenDetentType sheetLargestUndimmedDetent;
@property (nonatomic) NSNumber *sheetCustomLargestUndimmedDetent;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sheetUndimmedDetentIndex?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would choose even sheetLargestUndimmedDetent without Custom keyword. On the first look, the name sheetUndimmedDetentIndex isn't quite clear for me - what is Index there? 🤔

@property (nonatomic) BOOL sheetGrabberVisible;
@property (nonatomic) CGFloat sheetCornerRadius;
@property (nonatomic) BOOL sheetExpandsWhenScrolledToEdge;
@property (nonatomic) NSArray<NSNumber *> *sheetCustomDetents;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sheetDetentValues, sheetMaxDetentValueFractions or some variation of these?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would stay with sheetCustomDetents, as sheetAllowedDetents is already there and it would be confusing to use justsheetDetentValues. Also, sheetMaxDetentValueFractions seems a bit odd for me 🤔

customDetentWithIdentifier:ident
resolver:^CGFloat(
id<UISheetPresentationControllerDetentResolutionContext> ctx) {
return ctx.maximumDetentValue * frac.floatValue;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure yet whether I should use ctx.maximumDetentValue or try using large system detent height (by using resolveValueInContext:). Needs to be tested.

[NSMutableArray arrayWithCapacity:fractions.count];
int detentIndex = 0;
for (NSNumber *frac in fractions) {
NSString *ident = [[NSNumber numberWithInt:detentIndex] stringValue];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System API is organised is such way, that if I want to refer to particular detent I need to do it by its identifier (unique string). Thus I'm using indexes here. I rejected doing it by detent value (height) because the value is context dependent.

Alternative would be to make user define detents as objects with id / name field, e.g.:

[{
  id: "3/5",
  value: 0.6
}]

But I did not like this idea even more so.

This is why sheetCustomLargestUndimmedDetent (the name must be changed) takes an index as a value.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about if someone provided 2 same values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iOS will treat them as a single detent. The resolved value in such case is rather iOS implementation specific and not documented. It's better to treat such case as undefined behaviour.

I see your point though.

I guess we will stick to using indices.

ios/utils/NSArray+RNSUtil.h Outdated Show resolved Hide resolved
@kkafar kkafar requested review from kacperkapusciak, tboba and WoLewicki and removed request for kacperkapusciak August 9, 2023 14:58
ios/RNSScreen.mm Outdated Show resolved Hide resolved
Copy link
Member

@WoLewicki WoLewicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a quick review with some questions. Looks good overall 🎉

[NSMutableArray arrayWithCapacity:fractions.count];
int detentIndex = 0;
for (NSNumber *frac in fractions) {
NSString *ident = [[NSNumber numberWithInt:detentIndex] stringValue];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about if someone provided 2 same values?

ios/utils/NSArray+RNSUtil.h Outdated Show resolved Hide resolved
native-stack/README.md Outdated Show resolved Hide resolved
native-stack/README.md Outdated Show resolved Hide resolved
src/native-stack/types.tsx Outdated Show resolved Hide resolved
Copy link
Member

@tboba tboba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job with this one 🥳 However, I've got some suggestions that should be detented (haha) 😂😂🤣

FabricTestExample/src/Test1649.tsx Outdated Show resolved Hide resolved
FabricTestExample/src/Test1649.tsx Outdated Show resolved Hide resolved
ios/RNSScreen.mm Outdated Show resolved Hide resolved
@ferrannp
Copy link
Contributor

I am testing this and TBH, being able to do things like:

sheetCornerRadius: 20,
sheetAllowedDetents: [0.4, 0.9],
sheetLargestUndimmedDetent: 0,

is just amazing 🔥 .

However:

#1722 This bug is very bad. You do not notice it in a modal somehow but here is very noticeable see:

jumping.mov

There is an opened PR... but IMO we need to fix this. It kinda breaks the whole experience.

@kkafar
Copy link
Member Author

kkafar commented Aug 11, 2023

I'm aware of this flickering (on Fabric it gets even worse).

#1767 can't be merged unfortunately, because while it helps with this particular problem, it brings back other already solved issues.

I will see what can be done here.

@ansh
Copy link

ansh commented Oct 18, 2023

Will I be able to use a FlatList or ScrollView inside the modal with formSheet presentation?

@kkafar
Copy link
Member Author

kkafar commented Dec 13, 2023

Hey @ansh,

This PR is held back by #1974, as I want to have largest common API surface between platforms.

Also I'm still fighting with the "clipping" effect that was reported above. Haven't found a solution without tradeoffs yet (or maybe I just did, as I'm pushing right now).

@kkafar kkafar added the Feature impl PR with feature implementation label Dec 14, 2023
@kkafar kkafar self-assigned this Dec 14, 2023
kkafar added a commit that referenced this pull request Feb 21, 2024
## Description

Should be merged to #1852 or first rebased and then merged to main after
#1852.

Closes #1722 

So the exact roots of the issue are unclear & obfuscated. It seems that
it might be related to following (not 100% sure):

1. It looks like during animation `viewDidLayoutSubviews` is being
called which in turn calls `setSize:forView:` on UIManager. This
triggers Yoga layout underneath which sets view dimensions to the target
values (end animation values) resulting in view clipping during the
animation. There are two more important facts:

a. its hard to determine whether its Yoga who sets invalid value or it
gets invalid value from us (passed in `updateBounds` method after
`viewDidLayoutSubviews` is triggered by system.

b. when uimanager is not notified of bounds size change everything works
fine

I've considered various approaches:

1. Do not pass the value to UIManager when animation is ongoing.
Presence of animation was detected by checking `animationKeys` property
of view's layer. This still resulted in visual glitches. Moreover if I
sent the final value after animation finish (via completion block) it
resulted in content jumping (see
[here](facebook/react-native#34834 (comment))).
2. Use `CADisplayLink` & report to UIManager bounds size from
`presentationLayer` (that should be (almost) accurate value), but it
still resulted in visual glitches (even when sliding down), both
flickering and content jumping or just content had wrong top offset /
padding.
3. Do not notify UIManager at all on bounds change. 


## Changes

I went with this approach for now. That is: I do not notify uimanager on
bounds change when `stackPresentation == formSheet`. This is wild I
know. I experimented a bit trying to find out what did it broke, but I
did not find anything on my toy example (Test1649), however it is highly
unlikely that such approach does not have negative impact, but I believe
it is better that way, than having formsheets unusable due to this
flickering.


## Test code and steps to reproduce

Test1649

## Checklist

- [x] Included code example that can be used to test this change
- [x] Ensured that CI passes
@kkafar
Copy link
Member Author

kkafar commented Oct 8, 2024

Superseded by #2045

@kkafar kkafar closed this Oct 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature impl PR with feature implementation
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants