Skip to content
Open
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
8 changes: 6 additions & 2 deletions guides/GUIDE_FOR_LIBRARY_AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,17 +447,21 @@ function Home() {
}
```

### `unstable_sheetFooter` (Android only)
### `unstable_sheetFooter`

Footer component that can be used alongside form sheet stack presentation style.

This option is provided, because due to implementation details it might be problematic
to implement such layout with JS-only code.

The footer overlays the sheet content and is pinned to the bottom edge of the sheet,
moving above the keyboard when one appears. Reserve space for it (if needed) by padding
the sheet's scroll content.

Please note that this prop is marked as unstable and might be subject of breaking changes,
even removal.

Currently supported on Android only.
Supported on Android and iOS.

### `contentStyle`

Expand Down
7 changes: 7 additions & 0 deletions ios/RNSScreen.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ NS_ASSUME_NONNULL_BEGIN
@end

@class RNSScreenStackHeaderConfig;
@class RNSScreenFooter;

@interface RNSScreenView : RNSReactBaseView <RNSScreenContentWrapperDelegate,
RNSScrollViewBehaviorOverriding,
Expand Down Expand Up @@ -160,6 +161,12 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (RNSScreenStackHeaderConfig *_Nullable)findHeaderConfig;

/**
* Looks for a sheet footer (`unstable_sheetFooter`) in instance's `reactSubviews` and returns it.
* If not present returns `nil`.
*/
- (RNSScreenFooter *_Nullable)findSheetFooter;

/**
* Returns `YES` if the wrapper has been registered and it should not attempt to register on screen views higher in the
* tree.
Expand Down
20 changes: 20 additions & 0 deletions ios/RNSScreen.mm
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,26 @@ - (nullable RNSScreenStackHeaderConfig *)findHeaderConfig
return nil;
}

- (nullable RNSScreenFooter *)findSheetFooter
{
for (UIView *view in self.reactSubviews) {
if ([view isKindOfClass:RNSScreenFooter.class]) {
return (RNSScreenFooter *)view;
}
}

return nil;
}

// Keeps the sheet footer pinned to the bottom edge while UIKit resizes the
// screen view (detent changes, interactive detent drags, keyboard-driven
// sheet moves).
- (void)layoutSubviews
{
[super layoutSubviews];
[[self findSheetFooter] updateFooterPosition];
}

/// Looks for RCTScrollView in direct line - goes through the subviews at index 0 down the view hierarchy.
- (nullable RCTScrollViewComponentView *)tryFindDescendantScrollView
{
Expand Down
8 changes: 8 additions & 0 deletions ios/RNSScreenFooter.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ typedef void (^OnLayoutCallback)(CGRect frame);

@property (nonatomic, copy, nullable) OnLayoutCallback onLayout;

/**
* Pins the footer to the bottom edge of its parent RNSScreenView, lifting it
* above the keyboard when one overlaps the sheet. Idempotent - safe to call
* from any layout pass (layout metrics updates, parent layoutSubviews,
* keyboard frame changes).
*/
- (void)updateFooterPosition;

@end

@interface RNSScreenFooterManager : RCTViewManager
Expand Down
196 changes: 136 additions & 60 deletions ios/RNSScreenFooter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -5,91 +5,167 @@
#import <react/renderer/components/rnscreens/RCTComponentViewHelpers.h>
#import "RNSScreen.h"

// iOS implementation of the formSheet sheet footer (`unstable_sheetFooter`),
// mirroring the contract of Android's ScreenFooter:
//
// - The footer overlays the sheet content and is pinned to the bottom edge of
// the parent RNSScreenView. Consumers reserve space for it (if desired) by
// padding their scroll content; the footer can also be used as a surface
// for blur / scroll edge effects layered over the content.
// - Yoga lays the footer out as a regular child of the Screen (full width,
// height derived from its children, positioned in flow). We accept Yoga's
// size but re-pin `origin.y` to the bottom of the parent after every layout
// pass. Frames are used on purpose instead of Auto Layout constraints -
// Fabric assigns frames directly and fights constraint-based positioning.
// The pin is (re)applied from `updateLayoutMetrics:` (React-driven writes)
// and from the parent screen's `layoutSubviews` (UIKit-driven writes,
// including continuous resizes during interactive detent changes).
// - When the keyboard overlaps the sheet, the footer is lifted above it,
// animated with the keyboard's own duration & curve - the counterpart of
// the WindowInsetsAnimationCompat callback in Android's ScreenFooter.

@implementation RNSScreenFooter {
RNSScreenView *_parent;
// Keyboard end frame in screen coordinates. CGRectNull when hidden.
CGRect _keyboardFrameEnd;
}

- (instancetype)init
{
if (self = [super init]) {
self.translatesAutoresizingMaskIntoConstraints = false;
_parent = nil;
_keyboardFrameEnd = CGRectNull;
#if !TARGET_OS_TV && !TARGET_OS_VISION
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
selector:@selector(keyboardWillChangeFrame:)
name:UIKeyboardWillChangeFrameNotification
object:nil];
[center addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil];
#endif // !TARGET_OS_TV && !TARGET_OS_VISION
}
return self;
}

- (void)willMoveToSuperview:(UIView *)newSuperview
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}

- (nullable RNSScreenView *)screenView
{
if ([self.superview isKindOfClass:RNSScreenView.class]) {
return (RNSScreenView *)self.superview;
}
return nil;
}

#pragma mark - Positioning

- (CGFloat)keyboardOverlapWithParent:(UIView *)parent
{
if (CGRectIsNull(_keyboardFrameEnd) || CGRectIsEmpty(_keyboardFrameEnd) || parent.window == nil) {
return 0;
}
// Keyboard frames are delivered in screen coordinates; convert via the window.
CGRect frameInWindow = [parent.window convertRect:_keyboardFrameEnd fromWindow:nil];
CGRect frameInParent = [parent convertRect:frameInWindow fromView:parent.window];
return MAX(0, CGRectGetMaxY(parent.bounds) - CGRectGetMinY(frameInParent));
}

- (void)updateFooterPosition
{
[super willMoveToSuperview:newSuperview];
// if ([newSuperview isKindOfClass:RNSScreenView.class]) {
// RNSScreenView *screen = (RNSScreenView *)newSuperview;
// _parent = (RNSScreenView *)newSuperview;

// [NSLayoutConstraint activateConstraints:@[
// [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeBottom
// relatedBy:NSLayoutRelationEqual toItem:screen attribute:NSLayoutAttributeBottom multiplier:1.0
// constant:0.0], [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeLeft
// relatedBy:NSLayoutRelationEqual toItem:screen attribute:NSLayoutAttributeLeft multiplier:1.0 constant:0.0],
// [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual
// toItem:screen attribute:NSLayoutAttributeRight multiplier:1.0 constant:0.0], [NSLayoutConstraint
// constraintWithItem:self attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:screen
// attribute:NSLayoutAttributeTop multiplier:1.0 constant:0.0], [NSLayoutConstraint constraintWithItem:screen
// attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self
// attribute:NSLayoutAttributeBottom multiplier:1.0 constant:0.0], [NSLayoutConstraint
// constraintWithItem:screen attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self
// attribute:NSLayoutAttributeLeft multiplier:1.0 constant:0.0], [NSLayoutConstraint constraintWithItem:screen
// attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:self
// attribute:NSLayoutAttributeRight multiplier:1.0 constant:0.0], [NSLayoutConstraint constraintWithItem:screen
// attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeTop
// multiplier:1.0 constant:0.0],
// ]];
// [self setNeedsLayout];
// }
UIView *parent = [self screenView] ?: self.superview;
if (parent == nil) {
return;
}

CGRect frame = self.frame;
CGFloat targetY = parent.bounds.size.height - frame.size.height - [self keyboardOverlapWithParent:parent];
// Never push the footer above the top of the sheet.
targetY = MAX(0, targetY);

if (fabs(frame.origin.y - targetY) < 0.5) {
return;
}
frame.origin.y = targetY;
self.frame = frame;

if (self.onLayout != nil) {
self.onLayout(self.frame);
}
}

#pragma mark - UIView / Fabric lifecycle

- (void)didMoveToSuperview
{
if (_parent != nil) {
// [NSLayoutConstraint activateConstraints:@[
// [NSLayoutConstraint constraintWithItem:self
// attribute:NSLayoutAttributeBottom
// relatedBy:NSLayoutRelationEqual
// toItem:_parent
// attribute:NSLayoutAttributeBottom
// multiplier:1.0
// constant:0.0],
// [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeLeft
// relatedBy:NSLayoutRelationEqual toItem:_parent attribute:NSLayoutAttributeLeft multiplier:1.0
// constant:0.0], [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeRight
// relatedBy:NSLayoutRelationEqual toItem:_parent attribute:NSLayoutAttributeRight multiplier:1.0
// constant:0.0], [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeTop
// relatedBy:NSLayoutRelationEqual toItem:_parent attribute:NSLayoutAttributeTop multiplier:1.0
// constant:0.0], [NSLayoutConstraint constraintWithItem:_parent attribute:NSLayoutAttributeBottom
// relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeBottom multiplier:1.0
// constant:0.0], [NSLayoutConstraint constraintWithItem:_parent attribute:NSLayoutAttributeLeft
// relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeLeft multiplier:1.0 constant:0.0],
// [NSLayoutConstraint constraintWithItem:_parent attribute:NSLayoutAttributeRight
// relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeRight multiplier:1.0 constant:0.0],
// [NSLayoutConstraint constraintWithItem:_parent attribute:NSLayoutAttributeTop
// relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeTop multiplier:1.0 constant:0.0],
// ]];
// [self setNeedsLayout];
[super didMoveToSuperview];
if (self.superview != nil) {
[self updateFooterPosition];
}
}

- (void)layoutSubviews
{
[super layoutSubviews];
if (self.onLayout != nil) {
self.onLayout(self.frame);
[self updateFooterPosition];
}

- (void)updateLayoutMetrics:(const react::LayoutMetrics &)layoutMetrics
oldLayoutMetrics:(const react::LayoutMetrics &)oldLayoutMetrics
{
// Super applies Yoga's frame. Accept the size, then immediately re-pin to
// the bottom edge of the parent screen.
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
[self updateFooterPosition];
}

- (void)prepareForRecycle
{
[super prepareForRecycle];
_keyboardFrameEnd = CGRectNull;
}

#pragma mark - Keyboard

#if !TARGET_OS_TV && !TARGET_OS_VISION

- (void)keyboardWillChangeFrame:(NSNotification *)notification
{
_keyboardFrameEnd = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
[self animateFooterWithKeyboardNotification:notification];
}

- (void)keyboardWillHide:(NSNotification *)notification
{
_keyboardFrameEnd = CGRectNull;
[self animateFooterWithKeyboardNotification:notification];
}

- (void)animateFooterWithKeyboardNotification:(NSNotification *)notification
{
if (self.window == nil) {
return;
}
//
// if (self.subviews.count > 0) {
// CGSize childsSize = self.subviews[0].frame.size;
//
// }
NSTimeInterval duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
UIViewAnimationCurve curve =
(UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
// UIKit may move / resize the whole sheet in response to the keyboard as
// well; the parent's layoutSubviews re-pins the footer during that
// animation, this block covers the keyboard-only part of the movement.
[UIView animateWithDuration:duration
delay:0
options:(UIViewAnimationOptions)(curve << 16)
animations:^{
[self updateFooterPosition];
}
completion:nil];
}

#endif // !TARGET_OS_TV && !TARGET_OS_VISION

#pragma mark - Fabric registration

+ (react::ComponentDescriptorProvider)componentDescriptorProvider
{
return react::concreteComponentDescriptorProvider<react::RNSScreenFooterComponentDescriptor>();
Expand Down
2 changes: 1 addition & 1 deletion src/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ export interface ScreenProps extends ViewProps {
* including removal, in particular when we find solution that will make implementing it with JS
* straightforward.
*
* @platform android
* @platform android, ios
*/
unstable_sheetFooter?: (() => React.ReactNode) | undefined;
}
Expand Down