diff --git a/.gitignore b/.gitignore index f89dab57248..43e9462a5aa 100644 --- a/.gitignore +++ b/.gitignore @@ -245,4 +245,6 @@ lib/Mock/*.d.ts Mock.js Mock.d.ts -Gemfile.lock \ No newline at end of file +Gemfile.lock + +playground/ios/.xcode.env.local \ No newline at end of file diff --git a/lib/android/app/build.gradle b/lib/android/app/build.gradle index 3428f1a8820..4ebf14a7fab 100644 --- a/lib/android/app/build.gradle +++ b/lib/android/app/build.gradle @@ -194,7 +194,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.annotation:annotation:1.2.0' - implementation 'com.google.android.material:material:1.2.0-alpha03' + implementation 'com.google.android.material:material:1.9.0' implementation 'com.github.wix-playground:ahbottomnavigation:3.3.0' // implementation project(':AHBottomNavigation') diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutFactory.java b/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutFactory.java index 8a667162040..e89b45ac224 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutFactory.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutFactory.java @@ -22,6 +22,8 @@ import com.reactnativenavigation.viewcontrollers.externalcomponent.ExternalComponentCreator; import com.reactnativenavigation.viewcontrollers.externalcomponent.ExternalComponentPresenter; import com.reactnativenavigation.viewcontrollers.externalcomponent.ExternalComponentViewController; +import com.reactnativenavigation.viewcontrollers.sheet.SheetPresenter; +import com.reactnativenavigation.viewcontrollers.sheet.SheetViewController; import com.reactnativenavigation.viewcontrollers.sidemenu.SideMenuController; import com.reactnativenavigation.viewcontrollers.sidemenu.SideMenuPresenter; import com.reactnativenavigation.viewcontrollers.stack.StackControllerBuilder; @@ -32,6 +34,7 @@ import com.reactnativenavigation.viewcontrollers.viewcontroller.Presenter; import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController; import com.reactnativenavigation.views.component.ComponentViewCreator; +import com.reactnativenavigation.views.sheet.SheetViewCreator; import com.reactnativenavigation.views.stack.topbar.TopBarBackgroundViewCreator; import com.reactnativenavigation.views.stack.topbar.titlebar.TitleBarButtonCreator; import com.reactnativenavigation.views.stack.topbar.titlebar.TitleBarReactViewCreator; @@ -82,6 +85,8 @@ public ViewController create(final LayoutNode node) { return createComponent(node); case ExternalComponent: return createExternalComponent(context, node); + case Sheet: + return createSheet(node); case Stack: return createStack(node); case BottomTabs: @@ -101,6 +106,21 @@ public ViewController create(final LayoutNode node) { } } + private ViewController createSheet(LayoutNode node) { + String id = node.id; + String name = node.data.optString("name"); + return new SheetViewController(activity, + childRegistry, + id, + name, + new SheetViewCreator(reactInstanceManager), + parseOptions(node.getOptions()), + new Presenter(activity, defaultOptions), + new SheetPresenter(defaultOptions), + reactInstanceManager + ); + } + private ViewController createSideMenuRoot(LayoutNode node) { SideMenuController sideMenuController = new SideMenuController(activity, childRegistry, diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutNode.java b/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutNode.java index f65ac897d59..2f7277e176b 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutNode.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/options/LayoutNode.java @@ -7,6 +7,7 @@ public class LayoutNode { public enum Type { + Sheet, Component, ExternalComponent, Stack, diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutOptions.kt b/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutOptions.kt index 92e21150406..c8516ffb6a5 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutOptions.kt +++ b/lib/android/app/src/main/java/com/reactnativenavigation/options/layout/LayoutOptions.kt @@ -6,6 +6,7 @@ import com.reactnativenavigation.options.OrientationOptions import com.reactnativenavigation.options.params.* import com.reactnativenavigation.options.params.Number import com.reactnativenavigation.options.parsers.BoolParser +import com.reactnativenavigation.options.parsers.FloatParser import com.reactnativenavigation.options.parsers.NumberParser import org.json.JSONObject @@ -19,6 +20,15 @@ class LayoutOptions { @JvmField var topMargin: Number = NullNumber() + @JvmField + var sheetFullScreen: Bool = NullBool() + + @JvmField + var sheetBackdropOpacity: FloatParam = NullFloatParam() + + @JvmField + var sheetBorderTopRadius: Number = NullNumber() + @JvmField var adjustResize: Bool = NullBool() @@ -38,6 +48,9 @@ class LayoutOptions { if (other.orientation.hasValue()) orientation = other.orientation if (other.direction.hasValue()) direction = other.direction if (other.adjustResize.hasValue()) adjustResize = other.adjustResize + if (other.sheetFullScreen.hasValue()) sheetFullScreen = other.sheetFullScreen + if (other.sheetBackdropOpacity.hasValue()) sheetBackdropOpacity = other.sheetBackdropOpacity + if (other.sheetBorderTopRadius.hasValue()) sheetBorderTopRadius = other.sheetBorderTopRadius insets.merge(other.insets, null) } @@ -48,6 +61,9 @@ class LayoutOptions { if (!orientation.hasValue()) orientation = defaultOptions.orientation if (!direction.hasValue()) direction = defaultOptions.direction if (!adjustResize.hasValue()) adjustResize = defaultOptions.adjustResize + if (!sheetFullScreen.hasValue()) sheetFullScreen = defaultOptions.sheetFullScreen + if (!sheetBackdropOpacity.hasValue()) sheetBackdropOpacity = defaultOptions.sheetBackdropOpacity + if (!sheetBorderTopRadius.hasValue()) sheetBorderTopRadius = defaultOptions.sheetBorderTopRadius insets.merge(null, defaultOptions.insets) } @@ -64,8 +80,11 @@ class LayoutOptions { result.orientation = OrientationOptions.parse(json) result.direction = LayoutDirection.fromString(json.optString("direction", "")) result.adjustResize = BoolParser.parse(json, "adjustResize") + result.sheetFullScreen = BoolParser.parse(json, "sheetFullScreen") + result.sheetBorderTopRadius = NumberParser.parse(json, "sheetBorderTopRadius") + result.sheetBackdropOpacity = FloatParser.parse(json, "sheetBackdropOpacity") return result } } - + } \ No newline at end of file diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java b/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java index 4cc09eb770f..e501273ce24 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java @@ -211,6 +211,15 @@ public void dismissAllOverlays(String commandId, Promise promise) { handle(() -> navigator().dismissAllOverlays(new NativeCommandListener("dismissAllOverlays", commandId, promise, eventEmitter, now))); } + @ReactMethod + public void setupSheetContentNodes(String componentId, int headerTag, int contentTag, int footerTag, Promise promise) { + try { + navigator().setupSheetContentNodes(componentId, headerTag, contentTag, footerTag); + } catch(Exception e) {} // Nothing to do + + promise.resolve(null); + } + private Navigator navigator() { return activity().getNavigator(); } diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/react/ReactView.java b/lib/android/app/src/main/java/com/reactnativenavigation/react/ReactView.java index 90032dfd55d..89babb78dff 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/react/ReactView.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/react/ReactView.java @@ -24,7 +24,7 @@ public class ReactView extends ReactRootView implements IReactView, Renderable { private final ReactInstanceManager reactInstanceManager; - private final String componentId; + public final String componentId; private final String componentName; private boolean isAttachedToReactInstance = false; private final JSTouchDispatcher jsTouchDispatcher; diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java index 0e00f254364..712ead30669 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/modal/ModalStack.java @@ -9,6 +9,7 @@ import com.reactnativenavigation.react.CommandListener; import com.reactnativenavigation.react.CommandListenerAdapter; import com.reactnativenavigation.react.events.EventEmitter; +import com.reactnativenavigation.viewcontrollers.sheet.SheetViewController; import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController; import com.reactnativenavigation.viewcontrollers.viewcontroller.overlay.ModalOverlay; @@ -65,31 +66,48 @@ public void showModal(ViewController viewController, ViewController root, } public boolean dismissModal(String componentId, @Nullable ViewController root, CommandListener listener) { - ViewController toDismiss = findModalByComponentId(componentId); + ViewController toDismiss = findModalByComponentId(componentId); if (toDismiss != null) { - boolean isDismissingTopModal = isTop(toDismiss); - modals.remove(toDismiss); - @Nullable ViewController toAdd = isEmpty() ? root : isDismissingTopModal ? get(size() - 1) : null; - if (isDismissingTopModal) { - if (toAdd == null) { - listener.onError("Could not dismiss modal"); + if (toDismiss instanceof SheetViewController) { + SheetViewController sheetViewController = (SheetViewController) toDismiss; + + if (sheetViewController.sheetView.isPresented()) { + sheetViewController.sheetView.hide(); return false; } + + return doDismissModal(toDismiss, root, listener); + } else { + return doDismissModal(toDismiss, root, listener); } - presenter.dismissModal(toDismiss, toAdd, root, new CommandListenerAdapter(listener) { - @Override - public void onSuccess(String childId) { - eventEmitter.emitModalDismissed(toDismiss.getId(), toDismiss.getCurrentComponentName(), 1); - super.onSuccess(toDismiss.getId()); - } - }); - return true; } else { listener.onError("Nothing to dismiss"); return false; } } + public boolean doDismissModal(ViewController toDismiss, @Nullable ViewController root, CommandListener listener) { + boolean isDismissingTopModal = isTop(toDismiss); + modals.remove(toDismiss); + @Nullable ViewController toAdd = isEmpty() ? root : isDismissingTopModal ? get(size() - 1) : null; + if (isDismissingTopModal) { + if (toAdd == null) { + listener.onError("Could not dismiss modal"); + return false; + } + } + + presenter.dismissModal(toDismiss, toAdd, root, new CommandListenerAdapter(listener) { + @Override + public void onSuccess(String childId) { + eventEmitter.emitModalDismissed(toDismiss.getId(), toDismiss.getCurrentComponentName(), 1); + super.onSuccess(toDismiss.getId()); + } + }); + + return true; + } + public void dismissAllModals(@Nullable ViewController root, Options mergeOptions, CommandListener listener) { if (modals.isEmpty()) { listener.onSuccess(perform(root, "", ViewController::getId)); diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java index 80e6acfd624..5f138b43661 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/navigator/Navigator.java @@ -23,6 +23,7 @@ import com.reactnativenavigation.viewcontrollers.modal.ModalStack; import com.reactnativenavigation.viewcontrollers.overlay.OverlayManager; import com.reactnativenavigation.viewcontrollers.parent.ParentController; +import com.reactnativenavigation.viewcontrollers.sheet.SheetViewController; import com.reactnativenavigation.viewcontrollers.stack.StackController; import com.reactnativenavigation.viewcontrollers.viewcontroller.Presenter; import com.reactnativenavigation.viewcontrollers.viewcontroller.RootPresenter; @@ -249,6 +250,13 @@ private void applyOnStack(String fromId, CommandListener listener, Func1To send useful accessibility events, set a title on bottom sheets that are windows or are + * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for + * BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}. + */ +public class FixedBottomSheetBehavior extends CoordinatorLayout.Behavior { + + /** Callback for monitoring events about bottom sheets. */ + public abstract static class BottomSheetCallback { + + /** + * Called when the bottom sheet changes its state. + * + * @param bottomSheet The bottom sheet view. + * @param newState The new state. This will be one of {@link #STATE_DRAGGING}, {@link + * #STATE_SETTLING}, {@link #STATE_EXPANDED}, {@link #STATE_COLLAPSED}, {@link + * #STATE_HIDDEN}, or {@link #STATE_HALF_EXPANDED}. + */ + public abstract void onStateChanged(@NonNull View bottomSheet, @State int newState); + + /** + * Called when the bottom sheet is being dragged. + * + * @param bottomSheet The bottom sheet view. + * @param slideOffset The new offset of this bottom sheet within [-1,1] range. Offset increases + * as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and + * expanded states and from -1 to 0 it is between hidden and collapsed states. + */ + public abstract void onSlide(@NonNull View bottomSheet, float slideOffset); + + void onLayout(@NonNull View bottomSheet) {} + } + + /** The bottom sheet is dragging. */ + public static final int STATE_DRAGGING = 1; + + /** The bottom sheet is settling. */ + public static final int STATE_SETTLING = 2; + + /** The bottom sheet is expanded. */ + public static final int STATE_EXPANDED = 3; + + /** The bottom sheet is collapsed. */ + public static final int STATE_COLLAPSED = 4; + + /** The bottom sheet is hidden. */ + public static final int STATE_HIDDEN = 5; + + /** The bottom sheet is half-expanded (used when fitToContents is false). */ + public static final int STATE_HALF_EXPANDED = 6; + + /** @hide */ + @RestrictTo(LIBRARY_GROUP) + @IntDef({ + STATE_EXPANDED, + STATE_COLLAPSED, + STATE_DRAGGING, + STATE_SETTLING, + STATE_HIDDEN, + STATE_HALF_EXPANDED + }) + @Retention(RetentionPolicy.SOURCE) + public @interface State {} + + /** + * Stable states that can be set by the {@link #setState(int)} method. These includes all the + * possible states a bottom sheet can be in when it's settled. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + @IntDef({STATE_EXPANDED, STATE_COLLAPSED, STATE_HIDDEN, STATE_HALF_EXPANDED}) + @Retention(RetentionPolicy.SOURCE) + public @interface StableState {} + + /** + * Peek at the 16:9 ratio keyline of its parent. + * + *

This can be used as a parameter for {@link #setPeekHeight(int)}. {@link #getPeekHeight()} + * will return this when the value is set. + */ + public static final int PEEK_HEIGHT_AUTO = -1; + + /** This flag will preserve the peekHeight int value on configuration change. */ + public static final int SAVE_PEEK_HEIGHT = 0x1; + + /** This flag will preserve the fitToContents boolean value on configuration change. */ + public static final int SAVE_FIT_TO_CONTENTS = 1 << 1; + + /** This flag will preserve the hideable boolean value on configuration change. */ + public static final int SAVE_HIDEABLE = 1 << 2; + + /** This flag will preserve the skipCollapsed boolean value on configuration change. */ + public static final int SAVE_SKIP_COLLAPSED = 1 << 3; + + /** This flag will preserve all aforementioned values on configuration change. */ + public static final int SAVE_ALL = -1; + + /** + * This flag will not preserve the aforementioned values set at runtime if the view is destroyed + * and recreated. The only value preserved will be the positional state, e.g. collapsed, hidden, + * expanded, etc. This is the default behavior. + */ + public static final int SAVE_NONE = 0; + + /** @hide */ + @RestrictTo(LIBRARY_GROUP) + @IntDef( + flag = true, + value = { + SAVE_PEEK_HEIGHT, + SAVE_FIT_TO_CONTENTS, + SAVE_HIDEABLE, + SAVE_SKIP_COLLAPSED, + SAVE_ALL, + SAVE_NONE, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface SaveFlags {} + + private static final String TAG = "FixedBottomSheetBehavior"; + + @SaveFlags private int saveFlags = SAVE_NONE; + + @VisibleForTesting static final int DEFAULT_SIGNIFICANT_VEL_THRESHOLD = 500; + + private static final float HIDE_THRESHOLD = 0.5f; + + private static final float HIDE_FRICTION = 0.1f; + + private static final int CORNER_ANIMATION_DURATION = 500; + + private static final int NO_MAX_SIZE = -1; + + private static final int INVALID_POSITION = -1; + + private static final int VIEW_INDEX_BOTTOM_SHEET = 0; + + @VisibleForTesting + static final int VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW = 1; + + private boolean fitToContents = true; + + private boolean updateImportantForAccessibilityOnSiblings = false; + + private float maximumVelocity; + + private int significantVelocityThreshold; + + /** Peek height set by the user. */ + private int peekHeight; + + /** Whether or not to use automatic peek height. */ + private boolean peekHeightAuto; + + /** Minimum peek height permitted. */ + private int peekHeightMin; + + /** Peek height gesture inset buffer to ensure enough swipeable space. */ + private int peekHeightGestureInsetBuffer; + + private MaterialShapeDrawable materialShapeDrawable; + + @Nullable private ColorStateList backgroundTint; + + private int maxWidth = NO_MAX_SIZE; + + private int maxHeight = NO_MAX_SIZE; + + private int gestureInsetBottom; + private boolean gestureInsetBottomIgnored; + private boolean paddingBottomSystemWindowInsets; + private boolean paddingLeftSystemWindowInsets; + private boolean paddingRightSystemWindowInsets; + private boolean paddingTopSystemWindowInsets; + private boolean marginLeftSystemWindowInsets; + private boolean marginRightSystemWindowInsets; + private boolean marginTopSystemWindowInsets; + + private int insetBottom; + private int insetTop; + + private boolean shouldRemoveExpandedCorners; + + /** Default Shape Appearance to be used in bottomsheet */ + private ShapeAppearanceModel shapeAppearanceModelDefault; + + private boolean expandedCornersRemoved; + + private final StateSettlingTracker stateSettlingTracker = new StateSettlingTracker(); + + @Nullable private ValueAnimator interpolatorAnimator; + + private static final int DEF_STYLE_RES = R.style.Widget_Design_BottomSheet_Modal; + + int expandedOffset; + + int fitToContentsOffset; + + int halfExpandedOffset; + + float halfExpandedRatio = 0.5f; + + int collapsedOffset; + + float elevation = -1; + + boolean hideable; + + private boolean skipCollapsed; + + private boolean draggable = true; + + @State int state = STATE_COLLAPSED; + + @State int lastStableState = STATE_COLLAPSED; + + @Nullable ViewDragHelper viewDragHelper; + + private boolean ignoreEvents; + + private int lastNestedScrollDy; + + private boolean nestedScrolled; + + private float hideFriction = HIDE_FRICTION; + + private int childHeight; + int parentWidth; + int parentHeight; + + @Nullable WeakReference viewRef; + @Nullable WeakReference accessibilityDelegateViewRef; + + @Nullable WeakReference nestedScrollingChildRef; + + @NonNull private final ArrayList callbacks = new ArrayList<>(); + + @Nullable private VelocityTracker velocityTracker; + + int activePointerId; + + private int initialY = INVALID_POSITION; + + boolean touchingScrollingChild; + + private boolean isFullscreen = false; + + @Nullable private Map importantForAccessibilityMap; + + @VisibleForTesting + final SparseIntArray expandHalfwayActionIds = new SparseIntArray(); + + public FixedBottomSheetBehavior() {} + + public FixedBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + + peekHeightGestureInsetBuffer = + context.getResources().getDimensionPixelSize(R.dimen.mtrl_min_touch_target_size); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BottomSheetBehavior_Layout); + if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_backgroundTint)) { + this.backgroundTint = MaterialResources.getColorStateList( + context, a, R.styleable.BottomSheetBehavior_Layout_backgroundTint); + } + if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_shapeAppearance)) { + this.shapeAppearanceModelDefault = + ShapeAppearanceModel.builder(context, attrs, R.attr.bottomSheetStyle, DEF_STYLE_RES) + .build(); + } + createMaterialShapeDrawableIfNeeded(context); + createShapeValueAnimator(); + + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + this.elevation = a.getDimension(R.styleable.BottomSheetBehavior_Layout_android_elevation, -1); + } + + if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxWidth)) { + setMaxWidth( + a.getDimensionPixelSize( + R.styleable.BottomSheetBehavior_Layout_android_maxWidth, NO_MAX_SIZE)); + } + + if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxHeight)) { + setMaxHeight( + a.getDimensionPixelSize( + R.styleable.BottomSheetBehavior_Layout_android_maxHeight, NO_MAX_SIZE)); + } + + TypedValue value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight); + if (value != null && value.data == PEEK_HEIGHT_AUTO) { + setPeekHeight(value.data); + } else { + setPeekHeight( + a.getDimensionPixelSize( + R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight, PEEK_HEIGHT_AUTO)); + } + setHideable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_hideable, false)); + setGestureInsetBottomIgnored( + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_gestureInsetBottomIgnored, false)); + setFitToContents( + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_fitToContents, true)); + setSkipCollapsed( + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_skipCollapsed, false)); + setDraggable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_draggable, true)); + setSaveFlags(a.getInt(R.styleable.BottomSheetBehavior_Layout_behavior_saveFlags, SAVE_NONE)); + setHalfExpandedRatio( + a.getFloat(R.styleable.BottomSheetBehavior_Layout_behavior_halfExpandedRatio, 0.5f)); + + value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset); + if (value != null && value.type == TypedValue.TYPE_FIRST_INT) { + setExpandedOffset(value.data); + } else { + setExpandedOffset( + a.getDimensionPixelOffset( + R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset, 0)); + } + + setSignificantVelocityThreshold( + a.getInt( + R.styleable.BottomSheetBehavior_Layout_behavior_significantVelocityThreshold, + DEFAULT_SIGNIFICANT_VEL_THRESHOLD)); + + // Reading out if we are handling padding, so we can apply it to the content. + paddingBottomSystemWindowInsets = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingBottomSystemWindowInsets, false); + paddingLeftSystemWindowInsets = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingLeftSystemWindowInsets, false); + paddingRightSystemWindowInsets = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingRightSystemWindowInsets, false); + // Setting this to false will prevent the bottomsheet from going below the status bar. Since + // this is a breaking change from the old behavior the default is true. + paddingTopSystemWindowInsets = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingTopSystemWindowInsets, true); + marginLeftSystemWindowInsets = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginLeftSystemWindowInsets, false); + marginRightSystemWindowInsets = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginRightSystemWindowInsets, false); + marginTopSystemWindowInsets = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginTopSystemWindowInsets, false); + shouldRemoveExpandedCorners = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_shouldRemoveExpandedCorners, true); + + a.recycle(); + ViewConfiguration configuration = ViewConfiguration.get(context); + maximumVelocity = configuration.getScaledMaximumFlingVelocity(); + } + + public void makeFullscreen() { + this.isFullscreen = true; + } + + @NonNull + @Override + public Parcelable onSaveInstanceState(@NonNull CoordinatorLayout parent, @NonNull V child) { + return new SavedState(super.onSaveInstanceState(parent, child), this); + } + + @Override + public void onRestoreInstanceState( + @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull Parcelable state) { + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(parent, child, ss.getSuperState()); + // Restore Optional State values designated by saveFlags + restoreOptionalState(ss); + // Intermediate states are restored as collapsed state + if (ss.state == STATE_DRAGGING || ss.state == STATE_SETTLING) { + this.state = STATE_COLLAPSED; + this.lastStableState = this.state; + } else { + this.state = ss.state; + this.lastStableState = this.state; + } + } + + @Override + public void onAttachedToLayoutParams(@NonNull LayoutParams layoutParams) { + super.onAttachedToLayoutParams(layoutParams); + // These may already be null, but just be safe, explicitly assign them. This lets us know the + // first time we layout with this behavior by checking (viewRef == null). + viewRef = null; + viewDragHelper = null; + } + + @Override + public void onDetachedFromLayoutParams() { + super.onDetachedFromLayoutParams(); + // Release references so we don't run unnecessary codepaths while not attached to a view. + viewRef = null; + viewDragHelper = null; + } + + @Override + public boolean onMeasureChild( + @NonNull CoordinatorLayout parent, + @NonNull V child, + int parentWidthMeasureSpec, + int widthUsed, + int parentHeightMeasureSpec, + int heightUsed) { + MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + int childWidthMeasureSpec = + getChildMeasureSpec( + parentWidthMeasureSpec, + parent.getPaddingLeft() + + parent.getPaddingRight() + + lp.leftMargin + + lp.rightMargin + + widthUsed, + maxWidth, + lp.width); + int childHeightMeasureSpec = + getChildMeasureSpec( + parentHeightMeasureSpec, + parent.getPaddingTop() + + parent.getPaddingBottom() + + lp.topMargin + + lp.bottomMargin + + heightUsed, + maxHeight, + lp.height); + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + return true; // Child was measured + } + + private int getChildMeasureSpec( + int parentMeasureSpec, int padding, int maxSize, int childDimension) { + int result = ViewGroup.getChildMeasureSpec(parentMeasureSpec, padding, childDimension); + if (maxSize == NO_MAX_SIZE) { + return result; + } else { + int mode = MeasureSpec.getMode(result); + int size = MeasureSpec.getSize(result); + switch (mode) { + case MeasureSpec.EXACTLY: + return MeasureSpec.makeMeasureSpec(min(size, maxSize), MeasureSpec.EXACTLY); + case MeasureSpec.AT_MOST: + case MeasureSpec.UNSPECIFIED: + default: + return MeasureSpec.makeMeasureSpec( + size == 0 ? maxSize : min(size, maxSize), MeasureSpec.AT_MOST); + } + } + } + + @Override + public boolean onLayoutChild( + @NonNull CoordinatorLayout parent, @NonNull final V child, int layoutDirection) { + if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child)) { + child.setFitsSystemWindows(true); + } + + if (viewRef == null) { + // First layout with this behavior. + peekHeightMin = + parent.getResources().getDimensionPixelSize(R.dimen.design_bottom_sheet_peek_height_min); + setWindowInsetsListener(child); + ViewCompat.setWindowInsetsAnimationCallback(child, new InsetsAnimationCallback(child)); + viewRef = new WeakReference<>(child); + // Only set MaterialShapeDrawable as background if shapeTheming is enabled, otherwise will + // default to android:background declared in styles or layout. + if (materialShapeDrawable != null) { + ViewCompat.setBackground(child, materialShapeDrawable); + // Use elevation attr if set on bottomsheet; otherwise, use elevation of child view. + materialShapeDrawable.setElevation( + elevation == -1 ? ViewCompat.getElevation(child) : elevation); + } else if (backgroundTint != null) { + ViewCompat.setBackgroundTintList(child, backgroundTint); + } + updateAccessibilityActions(); + if (ViewCompat.getImportantForAccessibility(child) + == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { + ViewCompat.setImportantForAccessibility(child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + } + if (viewDragHelper == null) { + viewDragHelper = ViewDragHelper.create(parent, dragCallback); + } + + int savedTop = child.getTop(); + // First let the parent lay it out + parent.onLayoutChild(child, layoutDirection); + // Offset the bottom sheet + parentWidth = parent.getWidth(); + parentHeight = parent.getHeight(); + childHeight = child.getHeight(); + if (parentHeight - childHeight < insetTop) { + if (isFullscreen) { + childHeight = parentHeight; + } else { + int insetHeight = parentHeight - Math.round(PixelUtil.toPixelFromDIP(22)); + childHeight = (maxHeight == NO_MAX_SIZE) ? insetHeight : min(insetHeight, maxHeight); + } + } + fitToContentsOffset = max(0, parentHeight - childHeight); + calculateHalfExpandedOffset(); + calculateCollapsedOffset(); + + if (state == STATE_EXPANDED) { + ViewCompat.offsetTopAndBottom(child, getExpandedOffset()); + } else if (state == STATE_HALF_EXPANDED) { + ViewCompat.offsetTopAndBottom(child, halfExpandedOffset); + } else if (hideable && state == STATE_HIDDEN) { + ViewCompat.offsetTopAndBottom(child, parentHeight); + } else if (state == STATE_COLLAPSED) { + ViewCompat.offsetTopAndBottom(child, collapsedOffset); + } else if (state == STATE_DRAGGING || state == STATE_SETTLING) { + ViewCompat.offsetTopAndBottom(child, savedTop - child.getTop()); + } + updateDrawableForTargetState(state, /* animate= */ false); + + nestedScrollingChildRef = new WeakReference<>(findScrollingChild(child)); + + for (int i = 0; i < callbacks.size(); i++) { + callbacks.get(i).onLayout(child); + } + return true; + } + + @Override + public boolean onInterceptTouchEvent( + @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) { + if (!child.isShown() || !draggable) { + ignoreEvents = true; + return false; + } + int action = event.getActionMasked(); + // Record the velocity + if (action == MotionEvent.ACTION_DOWN) { + reset(); + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + velocityTracker.addMovement(event); + switch (action) { + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + touchingScrollingChild = false; + activePointerId = MotionEvent.INVALID_POINTER_ID; + // Reset the ignore flag + if (ignoreEvents) { + ignoreEvents = false; + return false; + } + break; + case MotionEvent.ACTION_DOWN: + int initialX = (int) event.getX(); + initialY = (int) event.getY(); + // Only intercept nested scrolling events here if the view not being moved by the + // ViewDragHelper. + if (state != STATE_SETTLING) { + View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; + if (scroll != null && parent.isPointInChildBounds(scroll, initialX, initialY)) { + activePointerId = event.getPointerId(event.getActionIndex()); + touchingScrollingChild = true; + } + } + ignoreEvents = + activePointerId == MotionEvent.INVALID_POINTER_ID + && !parent.isPointInChildBounds(child, initialX, initialY); + break; + default: // fall out + } + if (!ignoreEvents + && viewDragHelper != null + && viewDragHelper.shouldInterceptTouchEvent(event)) { + return true; + } + // We have to handle cases that the ViewDragHelper does not capture the bottom sheet because + // it is not the top most view of its parent. This is not necessary when the touch event is + // happening over the scrolling content as nested scrolling logic handles that case. + View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; + boolean cantScroll = false; + if (scroll != null) { + cantScroll = !scroll.canScrollVertically(1) && !scroll.canScrollVertically(-1); + } + + return action == MotionEvent.ACTION_MOVE + && scroll != null + && !ignoreEvents + && state != STATE_DRAGGING + && !cantScroll ? !parent.isPointInChildBounds(scroll, (int) event.getX(), (int) event.getY()) : true + && viewDragHelper != null + && initialY != INVALID_POSITION + && Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop(); + } + + @Override + public boolean onTouchEvent( + @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) { + if (!child.isShown()) { + return false; + } + int action = event.getActionMasked(); + if (state == STATE_DRAGGING && action == MotionEvent.ACTION_DOWN) { + return true; + } + if (shouldHandleDraggingWithHelper()) { + viewDragHelper.processTouchEvent(event); + } + // Record the velocity + if (action == MotionEvent.ACTION_DOWN) { + reset(); + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain(); + } + velocityTracker.addMovement(event); + // The ViewDragHelper tries to capture only the top-most View. We have to explicitly tell it + // to capture the bottom sheet in case it is not captured and the touch slop is passed. + if (shouldHandleDraggingWithHelper() && action == MotionEvent.ACTION_MOVE && !ignoreEvents) { + if (Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop()) { + viewDragHelper.captureChildView(child, event.getPointerId(event.getActionIndex())); + } + } + return !ignoreEvents; + } + + private void refreshNestedScrollingChildRef(View view) { + nestedScrollingChildRef = new WeakReference<>(view); + } + + @Override + public boolean onStartNestedScroll( + @NonNull CoordinatorLayout coordinatorLayout, + @NonNull V child, + @NonNull View directTargetChild, + @NonNull View target, + int axes, + int type) { + refreshNestedScrollingChildRef(target); + lastNestedScrollDy = 0; + nestedScrolled = false; + return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onNestedPreScroll( + @NonNull CoordinatorLayout coordinatorLayout, + @NonNull V child, + @NonNull View target, + int dx, + int dy, + @NonNull int[] consumed, + int type) { + if (type == ViewCompat.TYPE_NON_TOUCH) { + // Ignore fling here. The ViewDragHelper handles it. + + return; + } + View scrollingChild = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; + if (isNestedScrollingCheckEnabled() && target != scrollingChild) { + return; + } + int currentTop = child.getTop(); + int newTop = currentTop - dy; + if (dy > 0) { // Upward + if (newTop < getExpandedOffset()) { + consumed[1] = currentTop - getExpandedOffset(); + ViewCompat.offsetTopAndBottom(child, -consumed[1]); + setStateInternal(STATE_EXPANDED); + } else { + if (!draggable) { + // Prevent dragging + return; + } + + consumed[1] = dy; + ViewCompat.offsetTopAndBottom(child, -dy); + setStateInternal(STATE_DRAGGING); + } + } else if (dy < 0) { // Downward + if (!target.canScrollVertically(-1)) { + if (newTop <= collapsedOffset || canBeHiddenByDragging()) { + if (!draggable) { + // Prevent dragging + return; + } + + consumed[1] = dy; + ViewCompat.offsetTopAndBottom(child, -dy); + setStateInternal(STATE_DRAGGING); + } else { + consumed[1] = currentTop - collapsedOffset; + ViewCompat.offsetTopAndBottom(child, -consumed[1]); + setStateInternal(STATE_COLLAPSED); + } + } + } + dispatchOnSlide(child.getTop()); + lastNestedScrollDy = dy; + nestedScrolled = true; + } + + @Override + public void onStopNestedScroll( + @NonNull CoordinatorLayout coordinatorLayout, + @NonNull V child, + @NonNull View target, + int type) { + if (child.getTop() == getExpandedOffset()) { + setStateInternal(STATE_EXPANDED); + return; + } + if (isNestedScrollingCheckEnabled() + && (nestedScrollingChildRef == null + || target != nestedScrollingChildRef.get() + || !nestedScrolled)) { + return; + } + @StableState int targetState; + if (lastNestedScrollDy > 0) { + if (fitToContents) { + targetState = STATE_EXPANDED; + } else { + int currentTop = child.getTop(); + if (currentTop > halfExpandedOffset) { + targetState = STATE_HALF_EXPANDED; + } else { + targetState = STATE_EXPANDED; + } + } + } else if (hideable && shouldHide(child, getYVelocity())) { + targetState = STATE_HIDDEN; + } else if (lastNestedScrollDy == 0) { + int currentTop = child.getTop(); + if (fitToContents) { + if (Math.abs(currentTop - fitToContentsOffset) < Math.abs(currentTop - collapsedOffset)) { + targetState = STATE_EXPANDED; + } else { + targetState = STATE_COLLAPSED; + } + } else { + if (currentTop < halfExpandedOffset) { + if (currentTop < Math.abs(currentTop - collapsedOffset)) { + targetState = STATE_EXPANDED; + } else { + if (shouldSkipHalfExpandedStateWhenDragging()) { + targetState = STATE_COLLAPSED; + } else { + targetState = STATE_HALF_EXPANDED; + } + } + } else { + if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) { + targetState = STATE_HALF_EXPANDED; + } else { + targetState = STATE_COLLAPSED; + } + } + } + } else { + if (fitToContents) { + targetState = STATE_COLLAPSED; + } else { + // Settle to nearest height. + int currentTop = child.getTop(); + if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) { + targetState = STATE_HALF_EXPANDED; + } else { + targetState = STATE_COLLAPSED; + } + } + } + startSettling(child, targetState, false); + nestedScrolled = false; + } + + @Override + public void onNestedScroll( + @NonNull CoordinatorLayout coordinatorLayout, + @NonNull V child, + @NonNull View target, + int dxConsumed, + int dyConsumed, + int dxUnconsumed, + int dyUnconsumed, + int type, + @NonNull int[] consumed) { + // Overridden to prevent the default consumption of the entire scroll distance. + } + + @Override + public boolean onNestedPreFling( + @NonNull CoordinatorLayout coordinatorLayout, + @NonNull V child, + @NonNull View target, + float velocityX, + float velocityY) { + + if (isNestedScrollingCheckEnabled() && nestedScrollingChildRef != null) { + return target == nestedScrollingChildRef.get() + && (state != STATE_EXPANDED + || super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY)); + } else { + return false; + } + } + + /** + * @return whether the height of the expanded sheet is determined by the height of its contents, + * or if it is expanded in two stages (half the height of the parent container, full height of + * parent container). + */ + public boolean isFitToContents() { + return fitToContents; + } + + /** + * Sets whether the height of the expanded sheet is determined by the height of its contents, or + * if it is expanded in two stages (half the height of the parent container, full height of parent + * container). Default value is true. + * + * @param fitToContents whether or not to fit the expanded sheet to its contents. + */ + public void setFitToContents(boolean fitToContents) { + if (this.fitToContents == fitToContents) { + return; + } + this.fitToContents = fitToContents; + + // If sheet is already laid out, recalculate the collapsed offset based on new setting. + // Otherwise, let onLayoutChild handle this later. + if (viewRef != null) { + calculateCollapsedOffset(); + } + // Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents. + setStateInternal((this.fitToContents && state == STATE_HALF_EXPANDED) ? STATE_EXPANDED : state); + + updateDrawableForTargetState(state, /* animate= */ true); + updateAccessibilityActions(); + } + + /** + * Sets the maximum width of the bottom sheet. The layout will be at most this dimension wide. + * This method should be called before {@link BottomSheetDialog#show()} in order for the width to + * be adjusted as expected. + * + * @param maxWidth The maximum width in pixels to be set + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth + * @see #getMaxWidth() + */ + public void setMaxWidth(@Px int maxWidth) { + this.maxWidth = maxWidth; + } + + /** + * Returns the bottom sheet's maximum width, or -1 if no maximum width is set. + * + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth + * @see #setMaxWidth(int) + */ + @Px + public int getMaxWidth() { + return maxWidth; + } + + /** + * Sets the maximum height of the bottom sheet. This method should be called before {@link + * BottomSheetDialog#show()} in order for the height to be adjusted as expected. + * + * @param maxHeight The maximum height in pixels to be set + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight + * @see #getMaxHeight() + */ + public void setMaxHeight(@Px int maxHeight) { + this.maxHeight = maxHeight; + } + + /** + * Returns the bottom sheet's maximum height, or -1 if no maximum height is set. + * + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight + * @see #setMaxHeight(int) + */ + @Px + public int getMaxHeight() { + return maxHeight; + } + + /** + * Sets the height of the bottom sheet when it is collapsed. + * + * @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link + * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline. + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight + */ + public void setPeekHeight(int peekHeight) { + setPeekHeight(peekHeight, false); + } + + /** + * Sets the height of the bottom sheet when it is collapsed while optionally animating between the + * old height and the new height. + * + * @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link + * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline. + * @param animate Whether to animate between the old height and the new height. + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight + */ + public final void setPeekHeight(int peekHeight, boolean animate) { + boolean layout = false; + if (peekHeight == PEEK_HEIGHT_AUTO) { + if (!peekHeightAuto) { + peekHeightAuto = true; + layout = true; + } + } else if (peekHeightAuto || this.peekHeight != peekHeight) { + peekHeightAuto = false; + this.peekHeight = max(0, peekHeight); + layout = true; + } + // If sheet is already laid out, recalculate the collapsed offset based on new setting. + // Otherwise, let onLayoutChild handle this later. + if (layout) { + updatePeekHeight(animate); + } + } + + private void updatePeekHeight(boolean animate) { + if (viewRef != null) { + calculateCollapsedOffset(); + if (state == STATE_COLLAPSED) { + V view = viewRef.get(); + if (view != null) { + if (animate) { + setState(STATE_COLLAPSED); + } else { + view.requestLayout(); + } + } + } + } + } + + /** + * Gets the height of the bottom sheet when it is collapsed. + * + * @return The height of the collapsed bottom sheet in pixels, or {@link #PEEK_HEIGHT_AUTO} if the + * sheet is configured to peek automatically at 16:9 ratio keyline + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight + */ + public int getPeekHeight() { + return peekHeightAuto ? PEEK_HEIGHT_AUTO : peekHeight; + } + + /** + * Determines the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state. The + * material guidelines recommended a value of 0.5, which results in the sheet filling half of the + * parent. The height of the BottomSheet will be smaller as this ratio is decreased and taller as + * it is increased. The default value is 0.5. + * + * @param ratio a float between 0 and 1, representing the {@link #STATE_HALF_EXPANDED} ratio. + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio + */ + public void setHalfExpandedRatio( + @FloatRange(from = 0.0f, to = 1.0f, fromInclusive = false, toInclusive = false) float ratio) { + + if ((ratio <= 0) || (ratio >= 1)) { + throw new IllegalArgumentException("ratio must be a float value between 0 and 1"); + } + this.halfExpandedRatio = ratio; + // If sheet is already laid out, recalculate the half expanded offset based on new setting. + // Otherwise, let onLayoutChild handle this later. + if (viewRef != null) { + calculateHalfExpandedOffset(); + } + } + + /** + * Gets the ratio for the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state. + * + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio + */ + @FloatRange(from = 0.0f, to = 1.0f) + public float getHalfExpandedRatio() { + return halfExpandedRatio; + } + + /** + * Determines the top offset of the BottomSheet in the {@link #STATE_EXPANDED} state when + * fitsToContent is false. The default value is 0, which results in the sheet matching the + * parent's top. + * + * @param offset an integer value greater than equal to 0, representing the {@link + * #STATE_EXPANDED} offset. Value must not exceed the offset in the half expanded state. + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset + */ + public void setExpandedOffset(int offset) { + if (offset < 0) { + throw new IllegalArgumentException("offset must be greater than or equal to 0"); + } + this.expandedOffset = offset; + updateDrawableForTargetState(state, /* animate= */ true); + } + + /** + * Returns the current expanded offset. If {@code fitToContents} is true, it will automatically + * pick the offset depending on the height of the content. + * + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset + */ + public int getExpandedOffset() { + return fitToContents + ? fitToContentsOffset + : Math.max(expandedOffset, paddingTopSystemWindowInsets ? 0 : insetTop); + } + + /** + * Calculates the current offset of the bottom sheet. + * + * This method should be called when the child view is laid out. + * + * @return The offset of this bottom sheet within [-1,1] range. Offset increases + * as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and + * expanded states and from -1 to 0 it is between hidden and collapsed states. Returns + * -1 if the bottom sheet is not laid out (therefore it's hidden). + */ + public float calculateSlideOffset() { + if (viewRef == null || viewRef.get() == null) { + return -1; + } + + return calculateSlideOffsetWithTop(viewRef.get().getTop()); + } + + /** + * Sets whether this bottom sheet can hide. + * + * @param hideable {@code true} to make this bottom sheet hideable. + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable + */ + public void setHideable(boolean hideable) { + if (this.hideable != hideable) { + this.hideable = hideable; + if (!hideable && state == STATE_HIDDEN) { + // Lift up to collapsed state + setState(STATE_COLLAPSED); + } + updateAccessibilityActions(); + } + } + + /** + * Gets whether this bottom sheet can hide when it is swiped down. + * + * @return {@code true} if this bottom sheet can hide. + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable + */ + public boolean isHideable() { + return hideable; + } + + /** + * Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it + * is expanded once. Setting this to true has no effect unless the sheet is hideable. + * + * @param skipCollapsed True if the bottom sheet should skip the collapsed state. + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed + */ + public void setSkipCollapsed(boolean skipCollapsed) { + this.skipCollapsed = skipCollapsed; + } + + /** + * Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it + * is expanded once. + * + * @return Whether the bottom sheet should skip the collapsed state. + * @attr ref + * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed + */ + public boolean getSkipCollapsed() { + return skipCollapsed; + } + + /** + * Sets whether this bottom sheet is can be collapsed/expanded by dragging. Note: When disabling + * dragging, an app will require to implement a custom way to expand/collapse the bottom sheet + * + * @param draggable {@code false} to prevent dragging the sheet to collapse and expand + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_draggable + */ + public void setDraggable(boolean draggable) { + this.draggable = draggable; + } + + public boolean isDraggable() { + return draggable; + } + + /* + * Sets the velocity threshold considered significant enough to trigger a slide + * to the next stable state. + * + * @param significantVelocityThreshold The velocity threshold that warrants a vertical swipe. + * @see #getSignificantVelocityThreshold() + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_significantVelocityThreshold + */ + public void setSignificantVelocityThreshold(int significantVelocityThreshold) { + this.significantVelocityThreshold = significantVelocityThreshold; + } + + /* + * Returns the significant velocity threshold. + * + * @see #setSignificantVelocityThreshold(int) + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_significantVelocityThreshold + */ + public int getSignificantVelocityThreshold() { + return this.significantVelocityThreshold; + } + + /** + * Sets save flags to be preserved in bottomsheet on configuration change. + * + * @param flags bitwise int of {@link #SAVE_PEEK_HEIGHT}, {@link #SAVE_FIT_TO_CONTENTS}, {@link + * #SAVE_HIDEABLE}, {@link #SAVE_SKIP_COLLAPSED}, {@link #SAVE_ALL} and {@link #SAVE_NONE}. + * @see #getSaveFlags() + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags + */ + public void setSaveFlags(@SaveFlags int flags) { + this.saveFlags = flags; + } + /** + * Returns the save flags. + * + * @see #setSaveFlags(int) + * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags + */ + @SaveFlags + public int getSaveFlags() { + return this.saveFlags; + } + + /** + * Sets the friction coefficient to hide the bottom sheet, or set it to the next closest + * expanded state. + * + * @param hideFriction The friction coefficient that determines the swipe velocity needed to + * hide or set the bottom sheet to the closest expanded state. + */ + public void setHideFriction(float hideFriction) { + this.hideFriction = hideFriction; + } + + /** + * Gets the friction coefficient to hide the bottom sheet, or set it to the next closest + * expanded state. + * + * @return The friction coefficient that determines the swipe velocity needed to hide or set the + * bottom sheet to the closest expanded state. + */ + public float getHideFriction() { + return this.hideFriction; + } + + /** + * Sets a callback to be notified of bottom sheet events. + * + * @param callback The callback to notify when bottom sheet events occur. + * @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link + * #removeBottomSheetCallback(BottomSheetCallback)} instead + */ + @Deprecated + public void setBottomSheetCallback(BottomSheetCallback callback) { + Log.w( + TAG, + "BottomSheetBehavior now supports multiple callbacks. `setBottomSheetCallback()` removes" + + " all existing callbacks, including ones set internally by library authors, which" + + " may result in unintended behavior. This may change in the future. Please use" + + " `addBottomSheetCallback()` and `removeBottomSheetCallback()` instead to set your" + + " own callbacks."); + callbacks.clear(); + if (callback != null) { + callbacks.add(callback); + } + } + + /** + * Adds a callback to be notified of bottom sheet events. + * + * @param callback The callback to notify when bottom sheet events occur. + */ + public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) { + if (!callbacks.contains(callback)) { + callbacks.add(callback); + } + } + + /** + * Removes a previously added callback. + * + * @param callback The callback to remove. + */ + public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) { + callbacks.remove(callback); + } + + /** + * Sets the state of the bottom sheet. The bottom sheet will transition to that state with + * animation. + * + * @param state One of {@link #STATE_COLLAPSED}, {@link #STATE_EXPANDED}, {@link #STATE_HIDDEN}, + * or {@link #STATE_HALF_EXPANDED}. + */ + public void setState(@StableState int state) { + if (state == STATE_DRAGGING || state == STATE_SETTLING) { + throw new IllegalArgumentException( + "STATE_" + + (state == STATE_DRAGGING ? "DRAGGING" : "SETTLING") + + " should not be set externally."); + } + if (!hideable && state == STATE_HIDDEN) { + Log.w(TAG, "Cannot set state: " + state); + return; + } + final int finalState; + if (state == STATE_HALF_EXPANDED + && fitToContents + && getTopOffsetForState(state) <= fitToContentsOffset) { + // Skip to the expanded state if we would scroll past the height of the contents. + finalState = STATE_EXPANDED; + } else { + finalState = state; + } + if (viewRef == null || viewRef.get() == null) { + // The view is not laid out yet; modify mState and let onLayoutChild handle it later + setStateInternal(state); + } else { + final V child = viewRef.get(); + runAfterLayout( + child, + new Runnable() { + @Override + public void run() { + startSettling(child, finalState, false); + } + }); + } + } + + private void runAfterLayout(V child, Runnable runnable) { + if (isLayouting(child)) { + child.post(runnable); + } else { + runnable.run(); + } + } + + private boolean isLayouting(V child) { + ViewParent parent = child.getParent(); + return parent != null && parent.isLayoutRequested() && ViewCompat.isAttachedToWindow(child); + } + + /** + * Sets whether this bottom sheet should adjust it's position based on the system gesture area on + * Android Q and above. + * + *

Note: the bottom sheet will only adjust it's position if it would be unable to be scrolled + * upwards because the peekHeight is less than the gesture inset margins,(because that would cause + * a gesture conflict), gesture navigation is enabled, and this {@code ignoreGestureInsetBottom} + * flag is false. + */ + public void setGestureInsetBottomIgnored(boolean gestureInsetBottomIgnored) { + this.gestureInsetBottomIgnored = gestureInsetBottomIgnored; + } + + /** + * Returns whether this bottom sheet should adjust it's position based on the system gesture area. + */ + public boolean isGestureInsetBottomIgnored() { + return gestureInsetBottomIgnored; + } + + /** + * Sets whether the bottom sheet should remove its corners when it reaches the expanded state. + * + *

If false, the bottom sheet will only remove its corners if it is expanded and reaches the + * top of the screen. + */ + public void setShouldRemoveExpandedCorners(boolean shouldRemoveExpandedCorners) { + if (this.shouldRemoveExpandedCorners != shouldRemoveExpandedCorners) { + this.shouldRemoveExpandedCorners = shouldRemoveExpandedCorners; + updateDrawableForTargetState(getState(), /* animate= */ true); + } + } + + /** + * Returns whether the bottom sheet will remove its corners when it reaches the expanded state. + */ + public boolean isShouldRemoveExpandedCorners() { + return shouldRemoveExpandedCorners; + } + + /** + * Gets the current state of the bottom sheet. + * + * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED}, + * {@link #STATE_DRAGGING}, or {@link #STATE_SETTLING}. + */ + @State + public int getState() { + return state; + } + + void setStateInternal(@State int state) { + if (this.state == state) { + return; + } + this.state = state; + if (state == STATE_COLLAPSED + || state == STATE_EXPANDED + || state == STATE_HALF_EXPANDED + || (hideable && state == STATE_HIDDEN)) { + this.lastStableState = state; + } + + if (viewRef == null) { + return; + } + + View bottomSheet = viewRef.get(); + if (bottomSheet == null) { + return; + } + + if (state == STATE_EXPANDED) { + updateImportantForAccessibility(true); + } else if (state == STATE_HALF_EXPANDED || state == STATE_HIDDEN || state == STATE_COLLAPSED) { + updateImportantForAccessibility(false); + } + + updateDrawableForTargetState(state, /* animate= */ true); + for (int i = 0; i < callbacks.size(); i++) { + callbacks.get(i).onStateChanged(bottomSheet, state); + } + updateAccessibilityActions(); + } + + private void updateDrawableForTargetState(@State int state, boolean animate) { + if (state == STATE_SETTLING) { + // Special case: we want to know which state we're settling to, so wait for another call. + return; + } + + boolean removeCorners = isExpandedAndShouldRemoveCorners(); + if (expandedCornersRemoved == removeCorners || materialShapeDrawable == null) { + return; + } + expandedCornersRemoved = removeCorners; + if (animate && interpolatorAnimator != null) { + if (interpolatorAnimator.isRunning()) { + interpolatorAnimator.reverse(); + } else { + float to = removeCorners ? 0f : 1f; + float from = 1f - to; + interpolatorAnimator.setFloatValues(from, to); + interpolatorAnimator.start(); + } + } else { + if (interpolatorAnimator != null && interpolatorAnimator.isRunning()) { + interpolatorAnimator.cancel(); + } + materialShapeDrawable.setInterpolation(expandedCornersRemoved ? 0f : 1f); + } + } + + private boolean isExpandedAndShouldRemoveCorners() { + // Only remove corners when it's full screen. + return state == STATE_EXPANDED && (shouldRemoveExpandedCorners || getExpandedOffset() == 0); + } + + private int calculatePeekHeight() { + if (peekHeightAuto) { + int desiredHeight = max(peekHeightMin, parentHeight - parentWidth * 9 / 16); + return min(desiredHeight, childHeight) + insetBottom; + } + // Only make sure the peek height is above the gesture insets if we're not applying system + // insets. + if (!gestureInsetBottomIgnored && !paddingBottomSystemWindowInsets && gestureInsetBottom > 0) { + return max(peekHeight, gestureInsetBottom + peekHeightGestureInsetBuffer); + } + return peekHeight + insetBottom; + } + + private void calculateCollapsedOffset() { + int peek = calculatePeekHeight(); + + if (fitToContents) { + collapsedOffset = max(parentHeight - peek, fitToContentsOffset); + } else { + collapsedOffset = parentHeight - peek; + } + } + + private void calculateHalfExpandedOffset() { + this.halfExpandedOffset = (int) (parentHeight * (1 - halfExpandedRatio)); + } + + private float calculateSlideOffsetWithTop(int top) { + return + (top > collapsedOffset || collapsedOffset == getExpandedOffset()) + ? (float) (collapsedOffset - top) / (parentHeight - collapsedOffset) + : (float) (collapsedOffset - top) / (collapsedOffset - getExpandedOffset()); + } + + private void reset() { + activePointerId = ViewDragHelper.INVALID_POINTER; + initialY = INVALID_POSITION; + if (velocityTracker != null) { + velocityTracker.recycle(); + velocityTracker = null; + } + } + + private void restoreOptionalState(@NonNull SavedState ss) { + if (this.saveFlags == SAVE_NONE) { + return; + } + if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_PEEK_HEIGHT) == SAVE_PEEK_HEIGHT) { + this.peekHeight = ss.peekHeight; + } + if (this.saveFlags == SAVE_ALL + || (this.saveFlags & SAVE_FIT_TO_CONTENTS) == SAVE_FIT_TO_CONTENTS) { + this.fitToContents = ss.fitToContents; + } + if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_HIDEABLE) == SAVE_HIDEABLE) { + this.hideable = ss.hideable; + } + if (this.saveFlags == SAVE_ALL + || (this.saveFlags & SAVE_SKIP_COLLAPSED) == SAVE_SKIP_COLLAPSED) { + this.skipCollapsed = ss.skipCollapsed; + } + } + + boolean shouldHide(@NonNull View child, float yvel) { + if (skipCollapsed) { + return true; + } + if (!isHideableWhenDragging()) { + return false; + } + if (child.getTop() < collapsedOffset) { + // It should not hide, but collapse. + return false; + } + int peek = calculatePeekHeight(); + final float newTop = child.getTop() + yvel * hideFriction; + return Math.abs(newTop - collapsedOffset) / (float) peek > HIDE_THRESHOLD; + } + + @Nullable + @VisibleForTesting + View findScrollingChild(View view) { + if (view.getVisibility() != View.VISIBLE) { + return null; + } + if (ViewCompat.isNestedScrollingEnabled(view)) { + return view; + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0, count = group.getChildCount(); i < count; i++) { + View scrollingChild = findScrollingChild(group.getChildAt(i)); + if (scrollingChild != null) { + return scrollingChild; + } + } + } + return null; + } + + private boolean shouldHandleDraggingWithHelper() { + // If it's not draggable, do not forward events to viewDragHelper; however, if it's already + // dragging, let it finish. + return viewDragHelper != null && (draggable || state == STATE_DRAGGING); + } + + private void createMaterialShapeDrawableIfNeeded(@NonNull Context context) { + if (shapeAppearanceModelDefault == null) { + return; + } + + this.materialShapeDrawable = new MaterialShapeDrawable(shapeAppearanceModelDefault); + this.materialShapeDrawable.initializeElevationOverlay(context); + + if (backgroundTint != null) { + materialShapeDrawable.setFillColor(backgroundTint); + } else { + // If the tint isn't set, use the theme default background color. + TypedValue defaultColor = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.colorBackground, defaultColor, true); + materialShapeDrawable.setTint(defaultColor.data); + } + } + + MaterialShapeDrawable getMaterialShapeDrawable() { + return materialShapeDrawable; + } + + private void createShapeValueAnimator() { + interpolatorAnimator = ValueAnimator.ofFloat(0f, 1f); + interpolatorAnimator.setDuration(CORNER_ANIMATION_DURATION); + interpolatorAnimator.addUpdateListener( + new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(@NonNull ValueAnimator animation) { + float value = (float) animation.getAnimatedValue(); + if (materialShapeDrawable != null) { + materialShapeDrawable.setInterpolation(value); + } + } + }); + } + + private void setWindowInsetsListener(@NonNull View child) { + // Ensure the peek height is at least as large as the bottom gesture inset size so that + // the sheet can always be dragged, but only when the inset is required by the system. + final boolean shouldHandleGestureInsets = + VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto; + + // If were not handling insets at all, don't apply the listener. + if (!paddingBottomSystemWindowInsets + && !paddingLeftSystemWindowInsets + && !paddingRightSystemWindowInsets + && !marginLeftSystemWindowInsets + && !marginRightSystemWindowInsets + && !marginTopSystemWindowInsets + && !shouldHandleGestureInsets) { + return; + } + ViewUtils.doOnApplyWindowInsets( + child, + new ViewUtils.OnApplyWindowInsetsListener() { + @Override + @SuppressWarnings("deprecation") // getSystemWindowInsetBottom is used for adjustResize. + public WindowInsetsCompat onApplyWindowInsets( + View view, WindowInsetsCompat insets, RelativePadding initialPadding) { + Insets systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + Insets mandatoryGestureInsets = + insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); + + insetTop = systemBarInsets.top; + + boolean isRtl = ViewUtils.isLayoutRtl(view); + + int bottomPadding = view.getPaddingBottom(); + int leftPadding = view.getPaddingLeft(); + int rightPadding = view.getPaddingRight(); + + if (paddingBottomSystemWindowInsets) { + // Intentionally uses getSystemWindowInsetBottom to apply padding properly when + // adjustResize is used as the windowSoftInputMode. + insetBottom = insets.getSystemWindowInsetBottom(); + bottomPadding = initialPadding.bottom + insetBottom; + } + + if (paddingLeftSystemWindowInsets) { + leftPadding = isRtl ? initialPadding.end : initialPadding.start; + leftPadding += systemBarInsets.left; + } + + if (paddingRightSystemWindowInsets) { + rightPadding = isRtl ? initialPadding.start : initialPadding.end; + rightPadding += systemBarInsets.right; + } + + MarginLayoutParams mlp = (MarginLayoutParams) view.getLayoutParams(); + boolean marginUpdated = false; + + if (marginLeftSystemWindowInsets && mlp.leftMargin != systemBarInsets.left) { + mlp.leftMargin = systemBarInsets.left; + marginUpdated = true; + } + + if (marginRightSystemWindowInsets && mlp.rightMargin != systemBarInsets.right) { + mlp.rightMargin = systemBarInsets.right; + marginUpdated = true; + } + + if (marginTopSystemWindowInsets && mlp.topMargin != systemBarInsets.top) { + mlp.topMargin = systemBarInsets.top; + marginUpdated = true; + } + + if (marginUpdated) { + view.setLayoutParams(mlp); + } + view.setPadding(leftPadding, view.getPaddingTop(), rightPadding, bottomPadding); + + if (shouldHandleGestureInsets) { + gestureInsetBottom = mandatoryGestureInsets.bottom; + } + + // Don't update the peek height to be above the navigation bar or gestures if these + // flags are off. It means the client is already handling it. + if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) { + updatePeekHeight(/* animate= */ false); + } + return insets; + } + }); + } + + private float getYVelocity() { + if (velocityTracker == null) { + return 0; + } + velocityTracker.computeCurrentVelocity(1000, maximumVelocity); + return velocityTracker.getYVelocity(activePointerId); + } + + private void startSettling(View child, @StableState int state, boolean isReleasingView) { + int top = getTopOffsetForState(state); + boolean settling = + viewDragHelper != null + && (isReleasingView + ? viewDragHelper.settleCapturedViewAt(child.getLeft(), top) + : viewDragHelper.smoothSlideViewTo(child, child.getLeft(), top)); + if (settling) { + setStateInternal(STATE_SETTLING); + // STATE_SETTLING won't animate the material shape, so do that here with the target state. + updateDrawableForTargetState(state, /* animate= */ true); + stateSettlingTracker.continueSettlingToState(state); + } else { + setStateInternal(state); + } + } + + private int getTopOffsetForState(@StableState int state) { + switch (state) { + case STATE_COLLAPSED: + return collapsedOffset; + case STATE_EXPANDED: + return getExpandedOffset(); + case STATE_HALF_EXPANDED: + return halfExpandedOffset; + case STATE_HIDDEN: + return parentHeight; + default: + // Fall through + } + throw new IllegalArgumentException("Invalid state to get top offset: " + state); + } + + private final ViewDragHelper.Callback dragCallback = + new ViewDragHelper.Callback() { + + private long viewCapturedMillis; + + @Override + public boolean tryCaptureView(@NonNull View child, int pointerId) { + if (state == STATE_DRAGGING) { + return false; + } + if (touchingScrollingChild) { + return false; + } + if (state == STATE_EXPANDED && activePointerId == pointerId) { + View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null; + if (scroll != null && scroll.canScrollVertically(-1)) { + // Let the content scroll up + return false; + } + } + viewCapturedMillis = System.currentTimeMillis(); + return viewRef != null && viewRef.get() == child; + } + + @Override + public void onViewPositionChanged( + @NonNull View changedView, int left, int top, int dx, int dy) { + dispatchOnSlide(top); + } + + @Override + public void onViewDragStateChanged(@State int state) { + if (state == ViewDragHelper.STATE_DRAGGING && draggable) { + setStateInternal(STATE_DRAGGING); + } + } + + private boolean releasedLow(@NonNull View child) { + // Needs to be at least half way to the bottom. + return child.getTop() > (parentHeight + getExpandedOffset()) / 2; + } + + @Override + public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) { + @State int targetState; + if (yvel < 0) { // Moving up + if (fitToContents) { + targetState = STATE_EXPANDED; + } else { + int currentTop = releasedChild.getTop(); + long dragDurationMillis = System.currentTimeMillis() - viewCapturedMillis; + + if (shouldSkipHalfExpandedStateWhenDragging()) { + float yPositionPercentage = currentTop * 100f / parentHeight; + + if (shouldExpandOnUpwardDrag(dragDurationMillis, yPositionPercentage)) { + targetState = STATE_EXPANDED; + } else { + targetState = STATE_COLLAPSED; + } + } else { + if (currentTop > halfExpandedOffset) { + targetState = STATE_HALF_EXPANDED; + } else { + targetState = STATE_EXPANDED; + } + } + } + } else if (hideable && shouldHide(releasedChild, yvel)) { + // Hide if the view was either released low or it was a significant vertical swipe + // otherwise settle to closest expanded state. + if ((Math.abs(xvel) < Math.abs(yvel) && yvel > significantVelocityThreshold) + || releasedLow(releasedChild)) { + targetState = STATE_HIDDEN; + } else if (fitToContents) { + targetState = STATE_EXPANDED; + } else if (Math.abs(releasedChild.getTop() - getExpandedOffset()) + < Math.abs(releasedChild.getTop() - halfExpandedOffset)) { + targetState = STATE_EXPANDED; + } else { + targetState = STATE_HALF_EXPANDED; + } + } else if (yvel == 0.f || Math.abs(xvel) > Math.abs(yvel)) { + // If the Y velocity is 0 or the swipe was mostly horizontal indicated by the X velocity + // being greater than the Y velocity, settle to the nearest correct height. + int currentTop = releasedChild.getTop(); + if (fitToContents) { + if (Math.abs(currentTop - fitToContentsOffset) + < Math.abs(currentTop - collapsedOffset)) { + targetState = STATE_EXPANDED; + } else { + targetState = STATE_COLLAPSED; + } + } else { + if (currentTop < halfExpandedOffset) { + if (currentTop < Math.abs(currentTop - collapsedOffset)) { + targetState = STATE_EXPANDED; + } else { + if (shouldSkipHalfExpandedStateWhenDragging()) { + targetState = STATE_COLLAPSED; + } else { + targetState = STATE_HALF_EXPANDED; + } + } + } else { + if (Math.abs(currentTop - halfExpandedOffset) + < Math.abs(currentTop - collapsedOffset)) { + if (shouldSkipHalfExpandedStateWhenDragging()) { + targetState = STATE_COLLAPSED; + } else { + targetState = STATE_HALF_EXPANDED; + } + } else { + targetState = STATE_COLLAPSED; + } + } + } + } else { // Moving Down + if (fitToContents) { + targetState = STATE_COLLAPSED; + } else { + // Settle to the nearest correct height. + int currentTop = releasedChild.getTop(); + if (Math.abs(currentTop - halfExpandedOffset) + < Math.abs(currentTop - collapsedOffset)) { + if (shouldSkipHalfExpandedStateWhenDragging()) { + targetState = STATE_COLLAPSED; + } else { + targetState = STATE_HALF_EXPANDED; + } + } else { + targetState = STATE_COLLAPSED; + } + } + } + + startSettling(releasedChild, targetState, shouldSkipSmoothAnimation()); + } + + @Override + public int clampViewPositionVertical(@NonNull View child, int top, int dy) { + return MathUtils.clamp( + top, + getExpandedOffset(), + getViewVerticalDragRange(child)); + } + + @Override + public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) { + return child.getLeft(); + } + + @Override + public int getViewVerticalDragRange(@NonNull View child) { + if (canBeHiddenByDragging()) { + return parentHeight; + } else { + return collapsedOffset; + } + } + }; + + void dispatchOnSlide(int top) { + View bottomSheet = viewRef.get(); + if (bottomSheet != null && !callbacks.isEmpty()) { + float slideOffset = calculateSlideOffsetWithTop(top); + for (int i = 0; i < callbacks.size(); i++) { + callbacks.get(i).onSlide(bottomSheet, slideOffset); + } + } + } + + @VisibleForTesting + int getPeekHeightMin() { + return peekHeightMin; + } + + /** + * Disables the shaped corner {@link ShapeAppearanceModel} interpolation transition animations. + * Will have no effect unless the sheet utilizes a {@link MaterialShapeDrawable} with set shape + * theming properties. Only For use in UI testing. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + @VisibleForTesting + public void disableShapeAnimations() { + // Sets the shape value animator to null, prevents animations from occurring during testing. + interpolatorAnimator = null; + } + + /** + * Checks weather a nested scroll should be enabled. If {@code false} all nested scrolls will be + * consumed by the bottomSheet. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public boolean isNestedScrollingCheckEnabled() { + return true; + } + + /** + * Checks weather half expended state should be skipped when drag is ended. If {@code true}, the + * bottomSheet will go to the next closest state. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public boolean shouldSkipHalfExpandedStateWhenDragging() { + return false; + } + + /** + * Checks whether an animation should be smooth after the bottomSheet is released after dragging. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public boolean shouldSkipSmoothAnimation() { + return true; + } + + /** + * Checks whether hiding gestures should be enabled while {@code isHideable} is set to true. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public boolean isHideableWhenDragging() { + return true; + } + + private boolean canBeHiddenByDragging() { + return isHideable() && isHideableWhenDragging(); + } + + /** + * Checks whether the bottom sheet should be expanded after it has been released after dragging. + * + * @param dragDurationMillis how long the bottom sheet was dragged. + * @param yPositionPercentage position of the bottom sheet when released after dragging. Lower + * values mean that view was released closer to the top of the screen. + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public boolean shouldExpandOnUpwardDrag( + long dragDurationMillis, @FloatRange(from = 0.0f, to = 100.0f) float yPositionPercentage) { + return false; + } + + /** + * Sets whether this bottom sheet can hide when it is swiped down. + * + * @param hideable {@code true} to make this bottom sheet hideable. + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public void setHideableInternal(boolean hideable) { + this.hideable = hideable; + } + + /** + * Gets the last stable state of the bottom sheet. + * + * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED}, + * {@link #STATE_HIDDEN}. + * @hide + */ + @State + @RestrictTo(LIBRARY_GROUP) + public int getLastStableState() { + return lastStableState; + } + + private class StateSettlingTracker { + @State private int targetState; + private boolean isContinueSettlingRunnablePosted; + + private final Runnable continueSettlingRunnable = + new Runnable() { + @Override + public void run() { + isContinueSettlingRunnablePosted = false; + if (viewDragHelper != null && viewDragHelper.continueSettling(true)) { + continueSettlingToState(targetState); + } else if (state == STATE_SETTLING) { + setStateInternal(targetState); + } + // In other cases, settling has been interrupted by certain UX interactions. Do nothing. + } + }; + + void continueSettlingToState(@State int targetState) { + if (viewRef == null || viewRef.get() == null) { + return; + } + + this.targetState = targetState; + if (!isContinueSettlingRunnablePosted) { + ViewCompat.postOnAnimation(viewRef.get(), continueSettlingRunnable); + isContinueSettlingRunnablePosted = true; + } + } + } + + /** State persisted across instances */ + protected static class SavedState extends AbsSavedState { + @State final int state; + int peekHeight; + boolean fitToContents; + boolean hideable; + boolean skipCollapsed; + + public SavedState(@NonNull Parcel source) { + this(source, null); + } + + public SavedState(@NonNull Parcel source, ClassLoader loader) { + super(source, loader); + //noinspection ResourceType + state = source.readInt(); + peekHeight = source.readInt(); + fitToContents = source.readInt() == 1; + hideable = source.readInt() == 1; + skipCollapsed = source.readInt() == 1; + } + + public SavedState(Parcelable superState, @NonNull FixedBottomSheetBehavior behavior) { + super(superState); + this.state = behavior.state; + this.peekHeight = behavior.peekHeight; + this.fitToContents = behavior.fitToContents; + this.hideable = behavior.hideable; + this.skipCollapsed = behavior.skipCollapsed; + } + + /** + * This constructor does not respect flags: {@link BottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link + * BottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link BottomSheetBehavior#SAVE_HIDEABLE}, {@link + * BottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link BottomSheetBehavior#SAVE_NONE} + * were set. + * + * @deprecated Use {@link #SavedState(Parcelable, BottomSheetBehavior)} instead. + */ + @Deprecated + public SavedState(Parcelable superstate, @State int state) { + super(superstate); + this.state = state; + } + + @Override + public void writeToParcel(@NonNull Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeInt(state); + out.writeInt(peekHeight); + out.writeInt(fitToContents ? 1 : 0); + out.writeInt(hideable ? 1 : 0); + out.writeInt(skipCollapsed ? 1 : 0); + } + + public static final Creator CREATOR = + new ClassLoaderCreator() { + @NonNull + @Override + public SavedState createFromParcel(@NonNull Parcel in, ClassLoader loader) { + return new SavedState(in, loader); + } + + @Nullable + @Override + public SavedState createFromParcel(@NonNull Parcel in) { + return new SavedState(in, null); + } + + @NonNull + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + /** + * A utility function to get the {@link BottomSheetBehavior} associated with the {@code view}. + * + * @param view The {@link View} with {@link BottomSheetBehavior}. + * @return The {@link BottomSheetBehavior} associated with the {@code view}. + */ + @NonNull + @SuppressWarnings("unchecked") + public static FixedBottomSheetBehavior from(@NonNull V view) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (!(params instanceof CoordinatorLayout.LayoutParams)) { + throw new IllegalArgumentException("The view is not a child of CoordinatorLayout"); + } + CoordinatorLayout.Behavior behavior = + ((CoordinatorLayout.LayoutParams) params).getBehavior(); + if (!(behavior instanceof FixedBottomSheetBehavior)) { + throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior"); + } + return (FixedBottomSheetBehavior) behavior; + } + + /** + * Sets whether the BottomSheet should update the accessibility status of its {@link + * CoordinatorLayout} siblings when expanded. + * + *

Set this to true if the expanded state of the sheet blocks access to siblings (e.g., when + * the sheet expands over the full screen). + */ + public void setUpdateImportantForAccessibilityOnSiblings( + boolean updateImportantForAccessibilityOnSiblings) { + this.updateImportantForAccessibilityOnSiblings = updateImportantForAccessibilityOnSiblings; + } + + private void updateImportantForAccessibility(boolean expanded) { + if (viewRef == null) { + return; + } + + ViewParent viewParent = viewRef.get().getParent(); + if (!(viewParent instanceof CoordinatorLayout)) { + return; + } + + CoordinatorLayout parent = (CoordinatorLayout) viewParent; + final int childCount = parent.getChildCount(); + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) && expanded) { + if (importantForAccessibilityMap == null) { + importantForAccessibilityMap = new HashMap<>(childCount); + } else { + // The important for accessibility values of the child views have been saved already. + return; + } + } + + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + if (child == viewRef.get()) { + continue; + } + + if (expanded) { + // Saves the important for accessibility value of the child view. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + importantForAccessibilityMap.put(child, child.getImportantForAccessibility()); + } + if (updateImportantForAccessibilityOnSiblings) { + ViewCompat.setImportantForAccessibility( + child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + } + } else { + if (updateImportantForAccessibilityOnSiblings + && importantForAccessibilityMap != null + && importantForAccessibilityMap.containsKey(child)) { + // Restores the original important for accessibility value of the child view. + ViewCompat.setImportantForAccessibility(child, importantForAccessibilityMap.get(child)); + } + } + } + + if (!expanded) { + importantForAccessibilityMap = null; + } else if (updateImportantForAccessibilityOnSiblings) { + // If the siblings of the bottom sheet have been set to not important for a11y, move the focus + // to the bottom sheet when expanded. + viewRef.get().sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED); + } + } + + void setAccessibilityDelegateView(@Nullable View accessibilityDelegateView) { + if (accessibilityDelegateView == null && accessibilityDelegateViewRef != null) { + clearAccessibilityAction( + accessibilityDelegateViewRef.get(), VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); + accessibilityDelegateViewRef = null; + return; + } + accessibilityDelegateViewRef = new WeakReference<>(accessibilityDelegateView); + updateAccessibilityActions(accessibilityDelegateView, VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); + } + + private void updateAccessibilityActions() { + if (viewRef != null) { + updateAccessibilityActions(viewRef.get(), VIEW_INDEX_BOTTOM_SHEET); + } + if (accessibilityDelegateViewRef != null) { + updateAccessibilityActions( + accessibilityDelegateViewRef.get(), VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); + } + } + + private void updateAccessibilityActions(View view, int viewIndex) { + if (view == null) { + return; + } + clearAccessibilityAction(view, viewIndex); + + if (!fitToContents && state != STATE_HALF_EXPANDED) { + expandHalfwayActionIds.put( + viewIndex, + addAccessibilityActionForState( + view, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED)); + } + + if ((hideable && isHideableWhenDragging()) && state != STATE_HIDDEN) { + replaceAccessibilityActionForState( + view, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN); + } + + switch (state) { + case STATE_EXPANDED: + { + int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED; + replaceAccessibilityActionForState( + view, AccessibilityActionCompat.ACTION_COLLAPSE, nextState); + break; + } + case STATE_HALF_EXPANDED: + { + replaceAccessibilityActionForState( + view, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED); + replaceAccessibilityActionForState( + view, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED); + break; + } + case STATE_COLLAPSED: + { + int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED; + replaceAccessibilityActionForState( + view, AccessibilityActionCompat.ACTION_EXPAND, nextState); + break; + } + case STATE_HIDDEN: + case STATE_DRAGGING: + case STATE_SETTLING: + // Accessibility actions are not applicable, do nothing + } + } + + private void clearAccessibilityAction(View view, int viewIndex) { + if (view == null) { + return; + } + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_EXPAND); + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_DISMISS); + + int expandHalfwayActionId = expandHalfwayActionIds.get(viewIndex, View.NO_ID); + if (expandHalfwayActionId != View.NO_ID) { + ViewCompat.removeAccessibilityAction(view, expandHalfwayActionId); + expandHalfwayActionIds.delete(viewIndex); + } + } + + private void replaceAccessibilityActionForState( + View child, AccessibilityActionCompat action, @State int state) { + ViewCompat.replaceAccessibilityAction( + child, action, null, createAccessibilityViewCommandForState(state)); + } + + private int addAccessibilityActionForState( + View child, @StringRes int stringResId, @State int state) { + return ViewCompat.addAccessibilityAction( + child, + child.getResources().getString(stringResId), + createAccessibilityViewCommandForState(state)); + } + + private AccessibilityViewCommand createAccessibilityViewCommandForState(@State final int state) { + return new AccessibilityViewCommand() { + @Override + public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) { + setState(state); + return true; + } + }; + } +} + + +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + + + +class InsetsAnimationCallback extends WindowInsetsAnimationCompat.Callback { + + private final View view; + + private int startY; + private int startTranslationY; + + private final int[] tmpLocation = new int[2]; + + public InsetsAnimationCallback(View view) { + super(DISPATCH_MODE_STOP); + this.view = view; + } + + @Override + public void onPrepare(@NonNull WindowInsetsAnimationCompat windowInsetsAnimationCompat) { + view.getLocationOnScreen(tmpLocation); + startY = tmpLocation[1]; + } + + @NonNull + @Override + public BoundsCompat onStart( + @NonNull WindowInsetsAnimationCompat windowInsetsAnimationCompat, + @NonNull BoundsCompat boundsCompat) { + view.getLocationOnScreen(tmpLocation); + int endY = tmpLocation[1]; + startTranslationY = startY - endY; + + // Move the view back to its original position before the insets were applied. + view.setTranslationY(startTranslationY); + + return boundsCompat; + } + + @NonNull + @Override + public WindowInsetsCompat onProgress( + @NonNull WindowInsetsCompat insets, + @NonNull List animationList) { + for (WindowInsetsAnimationCompat animation : animationList) { + if ((animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0) { + // Move the view to match the animated position of the keyboard. + float translationY = + AnimationUtils.lerp(startTranslationY, 0, animation.getInterpolatedFraction()); + view.setTranslationY(translationY); + break; + } + } + return insets; + } + + @Override + public void onEnd(@NonNull WindowInsetsAnimationCompat windowInsetsAnimationCompat) { + view.setTranslationY(0f); + } +} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/sheet/SheetPresenter.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/sheet/SheetPresenter.java new file mode 100644 index 00000000000..a1a6dc6deaf --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/sheet/SheetPresenter.java @@ -0,0 +1,39 @@ +package com.reactnativenavigation.viewcontrollers.sheet; + +import com.reactnativenavigation.options.Options; +import com.reactnativenavigation.viewcontrollers.component.ComponentPresenterBase; +import com.reactnativenavigation.views.sheet.SheetLayout; + +public class SheetPresenter extends ComponentPresenterBase { + public Options defaultOptions; + + public SheetPresenter(Options defaultOptions) { + this.defaultOptions = defaultOptions; + } + + public void setDefaultOptions(Options defaultOptions) { + this.defaultOptions = defaultOptions; + } + + public void applyOptions(SheetLayout view, Options options) { + setBackgroundColor(view, options); + } + + public void mergeOptions(SheetLayout view, Options options) { + if (options.overlayOptions.interceptTouchOutside.hasValue()) + view.setInterceptTouchOutside(options.overlayOptions.interceptTouchOutside); + setBackgroundColor(view, options); + } + + private void setBackgroundColor(SheetLayout view, Options options) { + if (options.layout.componentBackgroundColor.hasValue()) { + view.setSheetBackgroundColor(options.layout.componentBackgroundColor.get()); + } + } + + public void onConfigurationChanged(SheetLayout view, Options options) { + if (view == null) return; + Options withDefault = options.withDefaultOptions(defaultOptions); + setBackgroundColor(view, withDefault); + } +} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/sheet/SheetViewController.java b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/sheet/SheetViewController.java new file mode 100644 index 00000000000..152b596db0e --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/sheet/SheetViewController.java @@ -0,0 +1,329 @@ +package com.reactnativenavigation.viewcontrollers.sheet; + +import android.app.Activity; +import android.content.res.Configuration; +import android.view.View; +import android.view.ViewGroup; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.uimanager.util.ReactFindViewUtil; +import com.facebook.react.views.scroll.ReactScrollView; +import com.reactnativenavigation.options.layout.LayoutOptions; +import com.reactnativenavigation.viewcontrollers.viewcontroller.ScrollEventListener; +import com.reactnativenavigation.options.Options; +import com.reactnativenavigation.viewcontrollers.viewcontroller.Presenter; +import com.reactnativenavigation.utils.SystemUiUtils; +import com.reactnativenavigation.viewcontrollers.viewcontroller.ReactViewCreator; +import com.reactnativenavigation.viewcontrollers.child.ChildController; +import com.reactnativenavigation.viewcontrollers.child.ChildControllersRegistry; +import androidx.annotation.NonNull; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import static com.reactnativenavigation.utils.ObjectUtils.perform; +import com.reactnativenavigation.views.sheet.SheetLayout; + +public class SheetViewController extends ChildController { + private final ReactInstanceManager reactInstanceManager; + private final String componentName; + private final SheetPresenter presenter; + private final ReactViewCreator viewCreator; + + private enum VisibilityState { + Appear, Disappear + } + + private VisibilityState lastVisibilityState = VisibilityState.Disappear; + + private final View.OnLayoutChangeListener contentViewLayoutChangeListener; + private ReactScrollView scrollView; + private ViewGroup headerView; + private ViewGroup footerView; + private ViewGroup contentView; + + private int contentHeight = 0; + + public final SheetLayout sheetView; + + private ReactFindViewUtil.OnViewFoundListener createOnViewFoundListener(int headerTag, int contentTag, int footerTag) { + return new ReactFindViewUtil.OnViewFoundListener() { + @Override + public String getNativeId() { + return "SheetContent-"+contentTag; + } + + @Override + public void onViewFound(final View view) { + if (view instanceof ReactScrollView) { + scrollView = (ReactScrollView) view; + contentView = (ViewGroup) scrollView.getChildAt(0); + if (contentView != null) { + getReactContext().runOnUiQueueThread((Runnable) () -> { + setupFooterAndHeader(headerTag, footerTag); + updateContentHeight(); + }); + + startListenLayoutChange(); + } else { + // Wait access to first child + scrollView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() { + @Override + public void onLayoutChange (View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { + scrollView.removeOnLayoutChangeListener(this); + contentView = (ViewGroup) scrollView.getChildAt(0); + getReactContext().runOnUiQueueThread((Runnable) () -> { + setupFooterAndHeader(headerTag, footerTag); + updateContentHeight(); + }); + + startListenLayoutChange(); + } + }); + } + } else if (view instanceof ViewGroup) { + contentView = (ViewGroup) view; + getReactContext().runOnUiQueueThread((Runnable) () -> { + setupFooterAndHeader(headerTag, footerTag); + updateContentHeight(); + }); + + startListenLayoutChange(); + } + } + }; + } + + public SheetViewController(final Activity activity, + final ChildControllersRegistry childRegistry, + final String id, + final String componentName, + final ReactViewCreator viewCreator, + final Options initialOptions, + final Presenter presenter, + final SheetPresenter componentPresenter, + final ReactInstanceManager reactInstanceManager) { + super(activity, childRegistry, id, presenter, initialOptions); + this.componentName = componentName; + this.viewCreator = viewCreator; + this.presenter = componentPresenter; + this.reactInstanceManager = reactInstanceManager; + + sheetView = (SheetLayout) viewCreator.create(getActivity(), getId(), componentName); + + LayoutOptions layoutOptions = resolveCurrentOptions(componentPresenter.defaultOptions).layout; + + if (layoutOptions.sheetBorderTopRadius.hasValue()) { + sheetView.setBorderTopRadius(layoutOptions.sheetBorderTopRadius.get()); + } + + if (layoutOptions.sheetBackdropOpacity.hasValue()) { + sheetView.setBackdropOpacity(layoutOptions.sheetBackdropOpacity.get()); + } + + this.contentViewLayoutChangeListener = new View.OnLayoutChangeListener() { + public void onLayoutChange(View view, int newLeft, int newRight, int newTop, int newBottom, int oldLeft, int oldRight, int oldTop, int oldBottom) { + updateContentHeight(); + } + }; + + if (layoutOptions.sheetFullScreen.isTrue()) { + sheetView.present(0); + } + } + + public void setupContentViews(int headerTag, int contentTag, int footerTag) { + LayoutOptions layoutOptions = resolveCurrentOptions(this.presenter.defaultOptions).layout; + if (layoutOptions.sheetFullScreen.isTrue()) { + return; + } + + ReactFindViewUtil.addViewListener(createOnViewFoundListener(headerTag, contentTag, footerTag)); + } + + private void setupFooterAndHeader(int headerTag, int footerTag) { + if(sheetView != null) { + headerView = (ViewGroup) ReactFindViewUtil.findView(sheetView, "SheetHeader-"+headerTag); + footerView = (ViewGroup) ReactFindViewUtil.findView(sheetView, "SheetFooter-"+footerTag); + } + } + + private void startListenLayoutChange() { + if (contentView != null) { + contentView.addOnLayoutChangeListener(this.contentViewLayoutChangeListener); + } + } + + private void stopListenLayoutChange() { + if (contentView != null) { + contentView.removeOnLayoutChangeListener(this.contentViewLayoutChangeListener); + } + } + + private void updateContentHeight() { + int newContentHeight = calcContentHeight(); + if (sheetView != null && contentHeight != newContentHeight) { + contentHeight = newContentHeight; + sheetView.present(newContentHeight); + } + } + + private int calcContentHeight() { + return (contentView != null ? contentView.getHeight() : 0) + + (headerView != null ? headerView.getHeight() : 0) + + (footerView != null ? footerView.getHeight() : 0); + } + + @Override + public void start() { + if (!isDestroyed()) + getView().start(); + } + + @Override + public String getCurrentComponentName() { + return this.componentName; + } + + @Override + public void setDefaultOptions(Options defaultOptions) { + super.setDefaultOptions(defaultOptions); + presenter.setDefaultOptions(defaultOptions); + } + + private ReactContext getReactContext() { + return reactInstanceManager.getCurrentReactContext(); + } + + @Override + public ScrollEventListener getScrollEventListener() { + return perform(view, null, SheetLayout::getScrollEventListener); + } + + @Override + public void onViewWillAppear() { + super.onViewWillAppear(); + if (view != null) + view.sendComponentWillStart(); + } + + @Override + public void onViewDidAppear() { + if (view != null) + view.sendComponentWillStart(); + super.onViewDidAppear(); + if (view != null) + view.requestApplyInsets(); + if (view != null && lastVisibilityState == VisibilityState.Disappear) + view.sendComponentStart(); + lastVisibilityState = VisibilityState.Appear; + } + + @Override + public void onViewDisappear() { + if (lastVisibilityState == VisibilityState.Disappear) + return; + lastVisibilityState = VisibilityState.Disappear; + if (view != null) + view.sendComponentStop(); + super.onViewDisappear(); + } + + @Override + public void sendOnNavigationButtonPressed(String buttonId) { + getView().sendOnNavigationButtonPressed(buttonId); + } + + @Override + public void applyOptions(Options options) { + if (isRoot()) + applyTopInset(); + super.applyOptions(options); + getView().applyOptions(options); + presenter.applyOptions(getView(), resolveCurrentOptions(presenter.defaultOptions)); + } + + @Override + public boolean isViewShown() { + return super.isViewShown() && view != null && view.isReady(); + } + + @NonNull + @Override + public SheetLayout createView() { + return (SheetLayout) sheetView.asView(); + } + + @Override + public void mergeOptions(Options options) { + if (options == Options.EMPTY) + return; + if (isViewShown()) + presenter.mergeOptions(getView(), options); + super.mergeOptions(options); + } + + @Override + public void applyTopInset() { + if (view != null) + presenter.applyTopInsets(view, getTopInset()); + } + + @Override + public int getTopInset() { + int statusBarInset = resolveCurrentOptions(presenter.defaultOptions).statusBar.isHiddenOrDrawBehind() ? 0 + : SystemUiUtils.getStatusBarHeight(getActivity()); + final Integer perform = perform(getParentController(), 0, p -> p.getTopInset(this)); + return statusBarInset + perform; + } + + @Override + public void applyBottomInset() { + if (view != null) + presenter.applyBottomInset(view, getBottomInset()); + } + + @Override + protected WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) { + final Insets systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + int systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + + insets.getInsets(WindowInsetsCompat.Type.navigationBars()).top - + systemBarsInsets.top; + int systemWindowInsetBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + + insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom - + systemBarsInsets.bottom; + + WindowInsetsCompat finalInsets = new WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.ime(), + Insets.of(systemBarsInsets.left, + systemWindowInsetTop, + systemBarsInsets.right, + Math.max(systemWindowInsetBottom - getBottomInset(), 0))) + .build(); + ViewCompat.onApplyWindowInsets(view, finalInsets); + return insets; + } + + @Override + public void destroy() { + final boolean blurOnUnmount = options != null && options.modal.blurOnUnmount.isTrue(); + if (blurOnUnmount) { + blurActivityFocus(); + } + stopListenLayoutChange(); + super.destroy(); + } + + private void blurActivityFocus() { + final Activity activity = getActivity(); + final View focusView = activity != null ? activity.getCurrentFocus() : null; + if (focusView != null) { + focusView.clearFocus(); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + presenter.onConfigurationChanged(view, options); + } +} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/views/component/ComponentLayout.java b/lib/android/app/src/main/java/com/reactnativenavigation/views/component/ComponentLayout.java index 845b882ed9a..8a92f1ef665 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/views/component/ComponentLayout.java +++ b/lib/android/app/src/main/java/com/reactnativenavigation/views/component/ComponentLayout.java @@ -14,13 +14,14 @@ import com.reactnativenavigation.react.events.ComponentType; import com.reactnativenavigation.viewcontrollers.stack.topbar.button.ButtonController; import com.reactnativenavigation.views.touch.OverlayTouchDelegate; +import com.reactnativenavigation.views.touch.TouchDelegateLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout; import static com.reactnativenavigation.utils.CoordinatorLayoutUtils.matchParentLP; @SuppressLint("ViewConstructor") -public class ComponentLayout extends CoordinatorLayout implements ReactComponent, ButtonController.OnClickListener { +public class ComponentLayout extends CoordinatorLayout implements ReactComponent, ButtonController.OnClickListener, TouchDelegateLayout { private boolean willAppearSent = false; private ReactView reactView; diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/views/sheet/SheetLayout.kt b/lib/android/app/src/main/java/com/reactnativenavigation/views/sheet/SheetLayout.kt new file mode 100644 index 00000000000..43235ba9488 --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/views/sheet/SheetLayout.kt @@ -0,0 +1,347 @@ +package com.reactnativenavigation.views.sheet + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.Outline +import android.graphics.Rect +import android.view.Gravity.CENTER_HORIZONTAL +import android.view.Gravity.TOP +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.doOnLayout +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.uimanager.PixelUtil +import com.reactnativenavigation.NavigationActivity +import com.reactnativenavigation.options.ButtonOptions +import com.reactnativenavigation.options.Options +import com.reactnativenavigation.options.params.Bool +import com.reactnativenavigation.react.CommandListenerAdapter +import com.reactnativenavigation.react.ReactView +import com.reactnativenavigation.react.events.ComponentType +import com.reactnativenavigation.utils.CoordinatorLayoutUtils.matchParentLP +import com.reactnativenavigation.viewcontrollers.sheet.FixedBottomSheetBehavior +import com.reactnativenavigation.viewcontrollers.stack.topbar.button.ButtonController +import com.reactnativenavigation.viewcontrollers.viewcontroller.ScrollEventListener +import com.reactnativenavigation.views.component.ReactComponent +import com.reactnativenavigation.views.touch.OverlayTouchDelegate +import com.reactnativenavigation.views.touch.TouchDelegateLayout + +@SuppressLint("ViewConstructor") +class SheetLayout(context: Context, private val reactView: ReactView) : + FrameLayout(context), + ReactComponent, + ButtonController.OnClickListener, + TouchDelegateLayout { + + private var willAppearSent = false + + private val touchDelegate: OverlayTouchDelegate + + private var screenHeight = 0 + + var isPresented = false; + + private val coordinatorView: CoordinatorLayout + + private val behavior: FixedBottomSheetBehavior + + val bottomSheet: FrameLayout + private var backdrop: View + + private var borderTopRadius = 12 + private var backdropOpacity = 0.5f + + private var activity: NavigationActivity? = null + + private var isChangingHeight = false; + + private var isSettling = false; + + private lateinit var rootView: View + + private lateinit var globalLayoutListener: ViewTreeObserver.OnGlobalLayoutListener + + private val bottomSheetCallback = object : FixedBottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + isSettling = when(newState) { + FixedBottomSheetBehavior.STATE_SETTLING -> true + else -> false + } + + if (newState == FixedBottomSheetBehavior.STATE_HIDDEN) { + isPresented = false + activity?.navigator?.dismissModal(reactView.componentId, CommandListenerAdapter()) + } + + if (!isPresented && newState == FixedBottomSheetBehavior.STATE_EXPANDED) { + isPresented = true + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + if (!isChangingHeight) { + onAnimationUpdateBackdrop(slideOffset) + } + } + } + + init { + coordinatorView = CoordinatorLayout(context).apply { + layoutParams = CoordinatorLayout.LayoutParams( + CoordinatorLayout.LayoutParams.MATCH_PARENT, + CoordinatorLayout.LayoutParams.MATCH_PARENT + ) + } + + addView(coordinatorView, matchParentLP()) + + backdrop = View(context).apply { + setBackgroundColor(Color.BLACK) + alpha = 0f + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + backdrop.setOnClickListener { + hide() + } + + coordinatorView.addView(backdrop, matchParentLP()) + + bottomSheet = FrameLayout(context).apply { + layoutParams = CoordinatorLayout.LayoutParams( + CoordinatorLayout.LayoutParams.MATCH_PARENT, + CoordinatorLayout.LayoutParams.MATCH_PARENT + ).apply { + gravity = CENTER_HORIZONTAL or TOP + behavior = FixedBottomSheetBehavior() + } + } + + coordinatorView.addView(bottomSheet) + + bottomSheet.addView(reactView) + + behavior = FixedBottomSheetBehavior.from(bottomSheet) + behavior.addBottomSheetCallback(bottomSheetCallback) + behavior.isFitToContents = true + behavior.isHideable = true + behavior.state = FixedBottomSheetBehavior.STATE_HIDDEN + behavior.skipCollapsed = true + behavior.saveFlags = FixedBottomSheetBehavior.SAVE_ALL + + // Update the usable sheet height + screenHeight = toPixel(640.0) + touchDelegate = OverlayTouchDelegate(this, reactView) + + if (context is NavigationActivity) { + activity = context + + rootView = context.findViewById(android.R.id.content) + globalLayoutListener = object : ViewTreeObserver.OnGlobalLayoutListener { + private var isKeyboardVisible = false + + override fun onGlobalLayout() { + val r = Rect() + rootView.getWindowVisibleDisplayFrame(r) + val screenHeight = rootView.rootView.height + val keypadHeight = screenHeight - r.bottom + + if (keypadHeight > screenHeight * 0.15) { + if (!isKeyboardVisible) { + isKeyboardVisible = true + // Fix autoFocus for input inside content + if (isSettling && !isPresented) { + UiThreadUtil.runOnUiThread { + present(bottomSheet.height) + } + } + } + } else { + if (isKeyboardVisible) { + isKeyboardVisible = false + } + } + } + } + + rootView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + screenHeight = height + } + + override fun hasOverlappingRendering() = true + + private fun onAnimationUpdateBackdrop(progress: Float) { + if (progress < 0) { + return + } + + backdrop.alpha = interpolate( + progress, + 0.0f to 1f, + 0.0f to backdropOpacity + ) + } + + fun hide() { + if (isPresented) { + behavior.state = FixedBottomSheetBehavior.STATE_HIDDEN + } else if (behavior.state == FixedBottomSheetBehavior.STATE_HIDDEN) { + activity?.navigator?.dismissModal(reactView.componentId, CommandListenerAdapter()) + } + } + + fun present(heightSheet: Int) { + if (!isPresented) { + if (height == 0) { + behavior.makeFullscreen(); + } else { + bottomSheet.roundTop(toPixel(borderTopRadius.toDouble())) + } + } + + var maxScreenHeight = screenHeight + if (height != -1) { + maxScreenHeight = screenHeight - toPixel(22.0) + } + + val adjustedHeight = heightSheet.coerceAtMost(maxScreenHeight) + + if (adjustedHeight != bottomSheet.height || !isPresented) { + val params = bottomSheet.layoutParams + if (adjustedHeight > 0) { + params.height = adjustedHeight + bottomSheet.layoutParams = params + } else { + params.height = CoordinatorLayout.LayoutParams.MATCH_PARENT + bottomSheet.layoutParams = params + } + + bottomSheet.doOnLayout { + behavior.state = FixedBottomSheetBehavior.STATE_EXPANDED + } + } + } + + private fun interpolate(x: Float, inputRange: Pair, outputRange: Pair): Float { + val (minX, maxX) = inputRange + val (minY, maxY) = outputRange + + return (x - minX) * ((maxY - minY) / (maxX - minX)) + minY + } + + override fun isReady(): Boolean { + return reactView.isReady + } + + override fun asView(): ViewGroup { + return this + } + + override fun destroy() { + reactView.destroy() + + if (rootView != null) { + rootView.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) + } + } + + fun start() { + reactView.start() + } + + fun sendComponentWillStart() { + if (!willAppearSent) { + reactView.sendComponentWillStart(ComponentType.Component) + } + willAppearSent = true + } + + fun sendComponentStart() { + reactView.sendComponentStart(ComponentType.Component) + } + + fun sendComponentStop() { + willAppearSent = false + reactView.sendComponentStop(ComponentType.Component) + } + + override fun sendOnNavigationButtonPressed(buttonId: String) { + reactView.sendOnNavigationButtonPressed(buttonId) + } + + fun applyOptions(options: Options) { + touchDelegate.interceptTouchOutside = options.overlayOptions.interceptTouchOutside + } + + fun setInterceptTouchOutside(interceptTouchOutside: Bool?) { + touchDelegate.interceptTouchOutside = interceptTouchOutside!! + } + + override fun getScrollEventListener(): ScrollEventListener? { + return reactView.scrollEventListener + } + + override fun dispatchTouchEventToJs(event: MotionEvent?) { + reactView.dispatchTouchEventToJs(event) + } + + override fun isRendered(): Boolean { + return reactView.isRendered + } + + override fun onPress(button: ButtonOptions) { + reactView.sendOnNavigationButtonPressed(button.id) + } + + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + return touchDelegate.onInterceptTouchEvent(ev!!) + } + + override fun superOnInterceptTouchEvent(event: MotionEvent): Boolean { + return super.onInterceptTouchEvent(event) + } + + fun setBorderTopRadius(value: Int) { + borderTopRadius = value + } + + fun setBackdropOpacity(value: Float) { + backdropOpacity = value + } + + fun setSheetBackgroundColor(color: Int) { + reactView.setBackgroundColor(color) + } +} + +fun toPixel(value: Double): Int = PixelUtil.toPixelFromDIP(value).toInt() + +fun View.roundTop(radius: Int) { + if (radius == 0) { + outlineProvider = null + clipToOutline = false + } else { + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height + radius * 2, radius.toFloat()) + } + } + clipToOutline = true + } +} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/views/sheet/SheetViewCreator.java b/lib/android/app/src/main/java/com/reactnativenavigation/views/sheet/SheetViewCreator.java new file mode 100644 index 00000000000..0f0f5d3ecd7 --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/views/sheet/SheetViewCreator.java @@ -0,0 +1,24 @@ +package com.reactnativenavigation.views.sheet; + +import android.app.Activity; + +import com.facebook.react.ReactInstanceManager; +import com.reactnativenavigation.viewcontrollers.viewcontroller.IReactView; +import com.reactnativenavigation.viewcontrollers.viewcontroller.ReactViewCreator; +import com.reactnativenavigation.react.ReactComponentViewCreator; +import com.reactnativenavigation.react.ReactView; + +public class SheetViewCreator implements ReactViewCreator { + + private ReactInstanceManager instanceManager; + + public SheetViewCreator(ReactInstanceManager instanceManager) { + this.instanceManager = instanceManager; + } + + @Override + public IReactView create(Activity activity, String componentId, String componentName) { + ReactView reactView = new ReactComponentViewCreator(instanceManager).create(activity, componentId, componentName); + return new SheetLayout(activity, reactView); + } +} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/views/touch/OverlayTouchDelegate.kt b/lib/android/app/src/main/java/com/reactnativenavigation/views/touch/OverlayTouchDelegate.kt index 2c789aee30c..d3005220f1b 100644 --- a/lib/android/app/src/main/java/com/reactnativenavigation/views/touch/OverlayTouchDelegate.kt +++ b/lib/android/app/src/main/java/com/reactnativenavigation/views/touch/OverlayTouchDelegate.kt @@ -6,21 +6,26 @@ import com.reactnativenavigation.options.params.Bool import com.reactnativenavigation.options.params.NullBool import com.reactnativenavigation.react.ReactView import com.reactnativenavigation.utils.coordinatesInsideView -import com.reactnativenavigation.views.component.ComponentLayout -open class OverlayTouchDelegate(private val component: ComponentLayout, private val reactView: ReactView) { +open class OverlayTouchDelegate( + private val component: TouchDelegateLayout, + private val reactView: ReactView +) { var interceptTouchOutside: Bool = NullBool() fun onInterceptTouchEvent(event: MotionEvent): Boolean { - return when (interceptTouchOutside.hasValue() && event.actionMasked == MotionEvent.ACTION_DOWN) { + return when (interceptTouchOutside.hasValue() && + event.actionMasked == MotionEvent.ACTION_DOWN + ) { true -> handleDown(event) false -> component.superOnInterceptTouchEvent(event) } } @VisibleForTesting - open fun handleDown(event: MotionEvent) = when (event.coordinatesInsideView(reactView.getChildAt(0))) { - true -> component.superOnInterceptTouchEvent(event) - false -> interceptTouchOutside.isFalse - } -} \ No newline at end of file + open fun handleDown(event: MotionEvent) = + when (event.coordinatesInsideView(reactView.getChildAt(0))) { + true -> component.superOnInterceptTouchEvent(event) + false -> interceptTouchOutside.isFalse + } +} diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/views/touch/TouchDelegateLayout.kt b/lib/android/app/src/main/java/com/reactnativenavigation/views/touch/TouchDelegateLayout.kt new file mode 100644 index 00000000000..8f1b7e19d5a --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/views/touch/TouchDelegateLayout.kt @@ -0,0 +1,7 @@ +package com.reactnativenavigation.views.touch + +import android.view.MotionEvent + +interface TouchDelegateLayout { + fun superOnInterceptTouchEvent(event: MotionEvent): Boolean +} diff --git a/lib/ios/RNNBridgeModule.m b/lib/ios/RNNBridgeModule.m index 0e43b8ee988..8ac7bdea3f9 100644 --- a/lib/ios/RNNBridgeModule.m +++ b/lib/ios/RNNBridgeModule.m @@ -252,4 +252,18 @@ - (instancetype)initWithCommandsHandler:(RNNCommandsHandler *)commandsHandler { return c; } +RCT_EXPORT_METHOD(setupSheetContentNodes + : (NSString *)componentId + : (nonnull NSNumber *)headerTag + : (nonnull NSNumber *)contentTag + : (nonnull NSNumber *)footerTag + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + [self->_commandsHandler setupSheetContentNodes:componentId + headerTag:headerTag + contentTag:contentTag + footerTag:footerTag]; + resolve(nil); +} + @end diff --git a/lib/ios/RNNCommandsHandler.h b/lib/ios/RNNCommandsHandler.h index 04842f6483c..2f4b7b0737f 100644 --- a/lib/ios/RNNCommandsHandler.h +++ b/lib/ios/RNNCommandsHandler.h @@ -84,4 +84,9 @@ - (void)dismissAllOverlays:(NSString *)commandId; +- (void)setupSheetContentNodes:(NSString *)componentId + headerTag:(NSNumber *)headerTag + contentTag:(NSNumber *)contentTag + footerTag:(NSNumber *)footerTag; + @end diff --git a/lib/ios/RNNCommandsHandler.m b/lib/ios/RNNCommandsHandler.m index 61b135fef53..f49f7240edf 100644 --- a/lib/ios/RNNCommandsHandler.m +++ b/lib/ios/RNNCommandsHandler.m @@ -5,6 +5,7 @@ #import "RNNConvert.h" #import "RNNDefaultOptionsHelper.h" #import "RNNErrorHandler.h" +#import "RNNSheetViewController.h" #import "React/RCTI18nUtil.h" #import "UINavigationController+RNNCommands.h" #import "UIViewController+RNNOptions.h" @@ -24,6 +25,7 @@ static NSString *const dismissAllOverlays = @"dismissAllOverlays"; static NSString *const mergeOptions = @"mergeOptions"; static NSString *const setDefaultOptions = @"setDefaultOptions"; +static NSString *const readyForPresentation = @"readyForPresentation"; @interface RNNCommandsHandler () @@ -499,6 +501,19 @@ - (void)dismissAllOverlays:(NSString *)commandId { [self->_eventEmitter sendOnNavigationCommandCompletion:dismissAllOverlays commandId:commandId]; } +- (void)setupSheetContentNodes:(NSString *)componentId + headerTag:(nonnull NSNumber *)headerTag + contentTag:(nonnull NSNumber *)contentTag + footerTag:(nonnull NSNumber *)footerTag { + UIViewController *viewController = + (UIViewController *)[_layoutManager findComponentForId:componentId]; + + if ([viewController isKindOfClass:[RNNSheetViewController class]]) { + RNNSheetViewController *sheetController = (RNNSheetViewController *)viewController; + [sheetController setupContentViews:headerTag contentTag:contentTag footerTag:footerTag]; + } +} + #pragma mark - private - (void)assertReady { diff --git a/lib/ios/RNNComponentPresenter.m b/lib/ios/RNNComponentPresenter.m index d1d55019632..1bfb3a795c5 100644 --- a/lib/ios/RNNComponentPresenter.m +++ b/lib/ios/RNNComponentPresenter.m @@ -1,5 +1,6 @@ #import "RNNComponentPresenter.h" #import "RNNComponentViewController.h" +#import "RNNSheetViewController.h" #import "TopBarTitlePresenter.h" #import "UITabBarController+RNNOptions.h" #import "UIViewController+RNNOptions.h" @@ -55,12 +56,25 @@ - (void)applyOptions:(RNNNavigationOptions *)options { [viewController setInterceptTouchOutside:[withDefault.overlay.interceptTouchOutside withDefault:YES]]; - if (@available(iOS 13.0, *)) { - [viewController setBackgroundColor:[withDefault.layout.componentBackgroundColor - withDefault:UIColor.systemBackgroundColor]]; + if ([viewController isKindOfClass:[RNNSheetViewController class]]) { + RNNSheetViewController *sheetController = (RNNSheetViewController *)viewController; + [sheetController setSheetBackgroundColor:[withDefault.layout.componentBackgroundColor + withDefault:UIColor.whiteColor]]; + + [sheetController + setBackdropOpacity:[withDefault.layout.sheetBackdropOpacity withDefault:0.6]]; + [sheetController + setCornerTopRadius:[withDefault.layout.sheetBorderTopRadius withDefault:@16]]; + } else { - [viewController setBackgroundColor:[withDefault.layout.componentBackgroundColor - withDefault:viewController.view.backgroundColor]]; + if (@available(iOS 13.0, *)) { + [viewController setBackgroundColor:[withDefault.layout.componentBackgroundColor + withDefault:UIColor.systemBackgroundColor]]; + } else { + [viewController + setBackgroundColor:[withDefault.layout.componentBackgroundColor + withDefault:viewController.view.backgroundColor]]; + } } if ([withDefault.topBar.searchBar.visible withDefault:NO]) { @@ -166,8 +180,26 @@ - (void)mergeOptions:(RNNNavigationOptions *)mergeOptions [viewController setTopBarPrefersLargeTitle:mergeOptions.topBar.largeTitle.visible.get]; } + if ([viewController isKindOfClass:[RNNSheetViewController class]]) { + RNNSheetViewController *sheetController = (RNNSheetViewController *)viewController; + + if (mergeOptions.layout.sheetBackdropOpacity.hasValue) { + [sheetController setBackdropOpacity:mergeOptions.layout.sheetBackdropOpacity.get]; + } + + if (mergeOptions.layout.sheetBorderTopRadius.hasValue) { + [sheetController setCornerTopRadius:mergeOptions.layout.sheetBorderTopRadius.get]; + } + } + if (mergeOptions.layout.componentBackgroundColor.hasValue) { - [viewController setBackgroundColor:mergeOptions.layout.componentBackgroundColor.get]; + if ([viewController isKindOfClass:[RNNSheetViewController class]]) { + RNNSheetViewController *sheetController = (RNNSheetViewController *)viewController; + [sheetController + setSheetBackgroundColor:mergeOptions.layout.componentBackgroundColor.get]; + } else { + [viewController setBackgroundColor:mergeOptions.layout.componentBackgroundColor.get]; + } } if (mergeOptions.bottomTab.badgeColor.hasValue) { diff --git a/lib/ios/RNNControllerFactory.m b/lib/ios/RNNControllerFactory.m index 30316144c7b..e25474ee5f4 100644 --- a/lib/ios/RNNControllerFactory.m +++ b/lib/ios/RNNControllerFactory.m @@ -4,6 +4,7 @@ #import "RNNBottomTabsController.h" #import "RNNComponentViewController.h" #import "RNNExternalViewController.h" +#import "RNNSheetViewController.h" #import "RNNSideMenuController.h" #import "RNNSplitViewController.h" #import "RNNStackController.h" @@ -104,6 +105,10 @@ - (UIViewController *)fromTree:(NSDictionary *)json { result = [self createSplitView:node]; } + else if (node.isSheet) { + result = [self createSheet:node]; + } + if (!result) { @throw [NSException exceptionWithName:@"UnknownControllerType" @@ -114,6 +119,36 @@ - (UIViewController *)fromTree:(NSDictionary *)json { return result; } +- (UIViewController *)createSheet:(RNNLayoutNode *)node { + RNNLayoutInfo *layoutInfo = [[RNNLayoutInfo alloc] initWithNode:node]; + RNNNavigationOptions *options = + [[RNNNavigationOptions alloc] initWithDict:node.data[@"options"]]; + + RNNButtonsPresenter *buttonsPresenter = + [[RNNButtonsPresenter alloc] initWithComponentRegistry:_componentRegistry + eventEmitter:_eventEmitter]; + RNNComponentPresenter *presenter = + [[RNNComponentPresenter alloc] initWithComponentRegistry:_componentRegistry + defaultOptions:_defaultOptions + buttonsPresenter:buttonsPresenter]; + RNNComponentViewController *RNComponent = + [[RNNComponentViewController alloc] initWithLayoutInfo:layoutInfo + rootViewCreator:_creator + eventEmitter:_eventEmitter + presenter:presenter + options:options + defaultOptions:_defaultOptions]; + + RNNSheetViewController *component = + [[RNNSheetViewController alloc] initWithLayoutInfo:layoutInfo + eventEmitter:_eventEmitter + presenter:presenter + options:options + defaultOptions:_defaultOptions + viewController:RNComponent]; + return component; +} + - (UIViewController *)createComponent:(RNNLayoutNode *)node { RNNLayoutInfo *layoutInfo = [[RNNLayoutInfo alloc] initWithNode:node]; RNNNavigationOptions *options = diff --git a/lib/ios/RNNLayoutNode.h b/lib/ios/RNNLayoutNode.h index 3850b2fba42..945b3118880 100644 --- a/lib/ios/RNNLayoutNode.h +++ b/lib/ios/RNNLayoutNode.h @@ -12,6 +12,7 @@ + (instancetype)create:(NSDictionary *)json; +- (BOOL)isSheet; - (BOOL)isComponent; - (BOOL)isExternalComponent; - (BOOL)isStack; diff --git a/lib/ios/RNNLayoutNode.m b/lib/ios/RNNLayoutNode.m index be1e7c36da8..1738fb0a673 100644 --- a/lib/ios/RNNLayoutNode.m +++ b/lib/ios/RNNLayoutNode.m @@ -13,6 +13,9 @@ + (instancetype)create:(NSDictionary *)json { return node; } +- (BOOL)isSheet { + return [self.type isEqualToString:@"Sheet"]; +} - (BOOL)isComponent { return [self.type isEqualToString:@"Component"]; } diff --git a/lib/ios/RNNLayoutOptions.h b/lib/ios/RNNLayoutOptions.h index 1fc4a79b7cb..352450d7915 100644 --- a/lib/ios/RNNLayoutOptions.h +++ b/lib/ios/RNNLayoutOptions.h @@ -4,6 +4,8 @@ @property(nonatomic, strong) Color *backgroundColor; @property(nonatomic, strong) Color *componentBackgroundColor; +@property(nonatomic, strong) Number *sheetBorderTopRadius; +@property(nonatomic, strong) Double *sheetBackdropOpacity; @property(nonatomic, strong) Text *direction; @property(nonatomic, strong) id orientation; @property(nonatomic, strong) Bool *autoHideHomeIndicator; diff --git a/lib/ios/RNNLayoutOptions.m b/lib/ios/RNNLayoutOptions.m index 11de293d51c..99e61999d24 100644 --- a/lib/ios/RNNLayoutOptions.m +++ b/lib/ios/RNNLayoutOptions.m @@ -12,10 +12,17 @@ - (instancetype)initWithDict:(NSDictionary *)dict { self.orientation = dict[@"orientation"]; self.autoHideHomeIndicator = [BoolParser parse:dict key:@"autoHideHomeIndicator"]; self.insets = [[RNNInsetsOptions alloc] initWithDict:dict[@"insets"]]; + self.sheetBorderTopRadius = [NumberParser parse:dict key:@"sheetBorderTopRadius"]; + self.sheetBackdropOpacity = [DoubleParser parse:dict key:@"sheetBackdropOpacity"]; + return self; } - (void)mergeOptions:(RNNLayoutOptions *)options { + if (options.sheetBorderTopRadius.hasValue) + self.sheetBorderTopRadius = options.sheetBorderTopRadius; + if (options.sheetBackdropOpacity.hasValue) + self.sheetBackdropOpacity = options.sheetBackdropOpacity; if (options.backgroundColor.hasValue) self.backgroundColor = options.backgroundColor; if (options.componentBackgroundColor.hasValue) diff --git a/lib/ios/RNNModalManager.m b/lib/ios/RNNModalManager.m index 6d49ed15f36..6043dad5c0b 100644 --- a/lib/ios/RNNModalManager.m +++ b/lib/ios/RNNModalManager.m @@ -1,6 +1,7 @@ #import "RNNModalManager.h" #import "RNNComponentViewController.h" #import "RNNConvert.h" +#import "RNNSheetViewController.h" #import "ScreenAnimationController.h" #import "ScreenReversedAnimationController.h" #import "UIViewController+LayoutProtocol.h" @@ -75,6 +76,12 @@ - (void)dismissModal:(UIViewController *)viewController animated:(BOOL)animated completion:(RNNTransitionCompletionBlock)completion { if (viewController) { + if ([viewController isKindOfClass:[RNNSheetViewController class]]) { + RNNSheetViewController *sheetVC = (RNNSheetViewController *)viewController; + [sheetVC dismiss]; + return; + } + [_pendingModalIdsToDismiss addObject:viewController]; [self removePendingNextModalIfOnTop:completion animated:animated]; } diff --git a/lib/ios/RNNSheetViewController.h b/lib/ios/RNNSheetViewController.h new file mode 100644 index 00000000000..8ac7e4c08ba --- /dev/null +++ b/lib/ios/RNNSheetViewController.h @@ -0,0 +1,49 @@ +#import "RNNComponentViewController.h" +#import + +static NSString *const NavigationLayoutElementHeaderID = @"NavigationLayoutHeader"; +static NSString *const NavigationLayoutElementContentID = @"NavigationLayoutContent"; +static NSString *const NavigationLayoutElementFooterID = @"NavigationLayoutFooter"; + +@interface RNNSheetViewController : RNNComponentViewController + +@property(nonatomic, strong) UIView *containerView; +@property(nonatomic, strong) UIView *backdrop; +@property(nonatomic, strong) UIView *bottomCompensator; +@property(nonatomic, assign) CGRect containerFrame; + +@property(nonatomic, weak) RCTScrollView *rctScrollView; +@property(nonatomic, weak) UIView *contentView; +@property(nonatomic, weak) UIView *headerView; +@property(nonatomic, weak) UIView *footerView; +@property(nonatomic, assign) CGFloat cachedHeight; + +@property(nonatomic, assign) BOOL isPresented; +@property(nonatomic, assign) BOOL isDragging; +@property(nonatomic, assign) BOOL isMoving; +@property(nonatomic, assign) BOOL isAnimatePresentInProcess; +@property(nonatomic, assign) CGFloat previousTranslation; +@property(nonatomic, assign) CGFloat startTranslationOffset; +@property(nonatomic, assign) CGFloat keyboardHeight; + +@property(nonatomic, assign) double backdropOpacity; +@property(nonatomic, assign) NSNumber *cornerTopRadius; + +@property(nonatomic, strong) UITapGestureRecognizer *tapGesture; +@property(nonatomic, strong) UIPanGestureRecognizer *panGesture; + +- (instancetype)initWithLayoutInfo:(RNNLayoutInfo *)layoutInfo + eventEmitter:(RNNEventEmitter *)eventEmitter + presenter:(RNNComponentPresenter *)presenter + options:(RNNNavigationOptions *)options + defaultOptions:(RNNNavigationOptions *)defaultOptions + viewController:(UIViewController *)viewController; +- (void)dismiss; + +- (void)setSheetBackgroundColor:(UIColor *)backgroundColor; + +- (void)setupContentViews:(nonnull NSNumber *)headerTag + contentTag:(nonnull NSNumber *)contentTag + footerTag:(nonnull NSNumber *)footerTag; + +@end diff --git a/lib/ios/RNNSheetViewController.m b/lib/ios/RNNSheetViewController.m new file mode 100644 index 00000000000..4762924fb37 --- /dev/null +++ b/lib/ios/RNNSheetViewController.m @@ -0,0 +1,612 @@ +#import "RNNSheetViewController.h" +#import "AnimationObserver.h" +#import + +@implementation RNNSheetViewController { + UIViewController *_boundViewController; + BOOL _stopFindScrollView; + RNNNavigationOptions *_options; + CGFloat _delta; +} + +- (instancetype)initWithLayoutInfo:(RNNLayoutInfo *)layoutInfo + eventEmitter:(RNNEventEmitter *)eventEmitter + presenter:(RNNComponentPresenter *)presenter + options:(RNNNavigationOptions *)options + defaultOptions:(RNNNavigationOptions *)defaultOptions + viewController:(UIViewController *)viewController { + _boundViewController = viewController; + + self = [super initWithLayoutInfo:layoutInfo + rootViewCreator:nil + eventEmitter:eventEmitter + presenter:presenter + options:options + defaultOptions:defaultOptions]; + + _options = options; + + self.isMoving = NO; + self.isDragging = NO; + self.previousTranslation = 0; + self.startTranslationOffset = 0; + + return self; +} + +#pragma mark - UIViewController overrides + +- (UIStatusBarStyle)preferredStatusBarStyle { + return [self.presenter getStatusBarStyle]; +} + +- (BOOL)prefersStatusBarHidden { + return [self.presenter getStatusBarVisibility]; +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return [self.presenter getOrientation]; +} + +- (BOOL)hidesBottomBarWhenPushed { + return [self.presenter hidesBottomBarWhenPushed]; +} + +- (void)loadView { + self.view = [[UIView alloc] initWithFrame:UIScreen.mainScreen.bounds]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self.eventEmitter sendComponentWillAppear:self.layoutInfo.componentId + componentName:self.layoutInfo.name + componentType:ComponentTypeScreen]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [[AnimationObserver sharedObserver] endAnimation]; + [self.eventEmitter sendComponentDidAppear:self.layoutInfo.componentId + componentName:self.layoutInfo.name + componentType:ComponentTypeScreen]; +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + [self.eventEmitter sendComponentDidDisappear:self.layoutInfo.componentId + componentName:self.layoutInfo.name + componentType:ComponentTypeScreen]; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + [self setup]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(keyboardWillShow:) + name:UIKeyboardWillShowNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(keyboardWillHide:) + name:UIKeyboardWillHideNotification + object:nil]; +} + +- (void)keyboardWillShow:(NSNotification *)notification { + NSDictionary *userInfo = [notification userInfo]; + NSValue *keyboardFrameValue = [userInfo objectForKey:UIKeyboardFrameEndUserInfoKey]; + CGRect keyboardFrame = [keyboardFrameValue CGRectValue]; + self.keyboardHeight = keyboardFrame.size.height; + + NSTimeInterval animationDuration = + [[userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationOptions animationCurve = + [[userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue] << 16; + + [self.backdrop.layer removeAllAnimations]; + [self.containerView.layer removeAllAnimations]; + + CGFloat sheetHeight = [self getSheetHeight]; + + CGFloat safeAreaBottom = + !self.footerView && self.keyboardHeight == 0 ? self.view.safeAreaInsets.bottom : 0; + CGFloat adjustedHeight = MIN(self.contentMaximumHeight, sheetHeight) + safeAreaBottom; + self.containerFrame = + CGRectMake(0, self.view.bounds.size.height - adjustedHeight - self.keyboardHeight, + self.view.bounds.size.width, adjustedHeight); + + _boundViewController.view.frame = + CGRectMake(0, 0, self.containerView.frame.size.width, adjustedHeight); + + self.isAnimatePresentInProcess = YES; + + [UIView animateWithDuration:animationDuration + delay:0 + options:animationCurve + animations:^{ + self.containerView.frame = self.containerFrame; + [self.view layoutIfNeeded]; + } + completion:^(BOOL finished) { + if (finished) { + self.isAnimatePresentInProcess = NO; + } + }]; + + if (self.rctScrollView) { + [self.rctScrollView layoutIfNeeded]; + } +} + +- (void)keyboardWillHide:(NSNotification *)notification { + self.keyboardHeight = 0; + + if (self.isPresented) { + [self.backdrop.layer removeAllAnimations]; + [self.containerView.layer removeAllAnimations]; + [self updateContentSize:[self getSheetHeight]]; + } +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + self.backdrop.frame = self.view.bounds; + + [self setupCornerRadius]; +} + +- (UINavigationItem *)navigationItem { + return _boundViewController.navigationItem; +} + +- (void)render { + [self readyForPresentation]; +} + +- (CGFloat)calcSheetHeight:(CGFloat)contentHeight { + return contentHeight + (self.headerView ? self.headerView.frame.size.height : 0) + + (self.footerView ? self.footerView.frame.size.height : 0); +} + +- (void)setSheetHeight:(CGFloat)contentHeight { + CGFloat sheetHeight = [self calcSheetHeight:contentHeight]; + if (sheetHeight > 0 && sheetHeight != self.cachedHeight) { + if (self.isPresented) { + [self updateContentSize:sheetHeight]; + } else { + [self present:sheetHeight]; + } + + if (self.rctScrollView) { + [self.rctScrollView.scrollView + setScrollEnabled:sheetHeight >= self.contentMaximumHeight]; + } + + self.cachedHeight = sheetHeight; + } +} + +- (void)setupContentViews:(nonnull NSNumber *)headerTag + contentTag:(nonnull NSNumber *)contentTag + footerTag:(nonnull NSNumber *)footerTag { + RNNReactView *reactView = _boundViewController.reactView; + RCTUIManager *uiManager = reactView.bridge.uiManager; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (headerTag) { + self.headerView = [uiManager viewForReactTag:headerTag]; + } + + if (footerTag) { + self.footerView = [uiManager viewForReactTag:footerTag]; + } + + if (contentTag) { + UIView *content = [uiManager viewForReactTag:contentTag]; + if (content) { + if ([content isKindOfClass:[RCTScrollView class]] && !self.rctScrollView) { + RCTScrollView *rctScrollView = (RCTScrollView *)content; + self.rctScrollView = rctScrollView; + + [self.rctScrollView.scrollView.panGestureRecognizer + addTarget:self + action:@selector(scrollViewPanGestureHandler:)]; + [self setSheetHeight:rctScrollView.scrollView.contentSize.height]; + + [self.rctScrollView.scrollView addObserver:self + forKeyPath:@"contentSize" + options:NSKeyValueObservingOptionNew + context:NULL]; + } else if (!self.contentView) { + self.contentView = content; + [self setSheetHeight:content.bounds.size.height]; + [self.contentView addObserver:self + forKeyPath:@"frame" + options:NSKeyValueObservingOptionNew + context:nil]; + } + } else { + @throw [NSException exceptionWithName:@"Failed present Sheet" + reason:@"Failed find element by tag" + userInfo:nil]; + } + } else { + @throw [NSException exceptionWithName:@"Failed present Sheet" + reason:@"contentTag does not exist" + userInfo:nil]; + } + }); +} + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context { + if ([keyPath isEqualToString:@"contentSize"]) { + CGSize newContentSize = [change[NSKeyValueChangeNewKey] CGSizeValue]; + [self setSheetHeight:newContentSize.height]; + } else if ([keyPath isEqualToString:@"frame"]) { + CGRect newFrame = [[change objectForKey:NSKeyValueChangeNewKey] CGRectValue]; + [self setSheetHeight:newFrame.size.height]; + } else { + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; + } +} + +#pragma mark - Present And update sizes + +- (CGFloat)contentMaximumHeight { + return self.view.frame.size.height - self.view.safeAreaInsets.top - + self.view.safeAreaInsets.bottom; +} + +- (void)present:(CGFloat)height { + if (self.isPresented) + return; + CGFloat safeAreaBottom = self.keyboardHeight == 0 ? self.view.safeAreaInsets.bottom : 0; + CGFloat adjustedHeight = MIN(self.contentMaximumHeight, height) + safeAreaBottom; + + self.containerFrame = + CGRectMake(0, self.view.bounds.size.height - adjustedHeight - self.keyboardHeight, + self.view.bounds.size.width, adjustedHeight); + self.containerView.frame = + CGRectMake(0, self.view.bounds.size.height, self.view.bounds.size.width, adjustedHeight); + + _boundViewController.view.frame = CGRectMake( + 0, 0, self.containerFrame.size.width, + self.footerView ? adjustedHeight - self.view.safeAreaInsets.bottom : adjustedHeight); + + self.isAnimatePresentInProcess = YES; + [self + animate:^{ + self.containerView.frame = + CGRectMake(0, self.view.bounds.size.height - self.containerView.frame.size.height, + self.view.bounds.size.width, self.containerView.frame.size.height); + self.backdrop.backgroundColor = + [[UIColor blackColor] colorWithAlphaComponent:self.backdropOpacity]; + } + velocity:0.0 + completion:^(BOOL finished) { + if (finished) { + self.isAnimatePresentInProcess = NO; + } + }]; + + self.isPresented = true; +} + +- (void)updateContentSize:(CGFloat)height { + CGFloat safeAreaBottom = self.keyboardHeight == 0 ? self.view.safeAreaInsets.bottom : 0; + CGFloat adjustedHeight = MIN(self.contentMaximumHeight, height) + safeAreaBottom; + self.containerFrame = + CGRectMake(0, self.view.bounds.size.height - adjustedHeight - self.keyboardHeight, + self.view.bounds.size.width, adjustedHeight); + + _boundViewController.view.frame = CGRectMake( + 0, 0, self.containerView.frame.size.width, + self.footerView ? adjustedHeight - self.view.safeAreaInsets.bottom : adjustedHeight); + + self.isAnimatePresentInProcess = YES; + [self + animate:^{ + self.containerView.frame = self.containerFrame; + } + velocity:0.0 + completion:^(BOOL finished) { + if (finished) { + self.isAnimatePresentInProcess = NO; + } + }]; +} + +- (CGFloat)getSheetHeight { + CGFloat contentHeight = 0; + if (self.rctScrollView) { + contentHeight = self.rctScrollView.contentSize.height; + } else if (self.contentView) { + contentHeight = self.contentView.frame.size.height; + } + + return [self calcSheetHeight:contentHeight]; +} + +- (void)dismiss { + [self + dismissWithCompletion:^{ + } + velocity:0.0]; +} + +- (void)dismissWithVelocity:(CGFloat)velocity { + [self + dismissWithCompletion:^{ + } + velocity:velocity]; +} + +- (void)dismissWithCompletion:(void (^)(void))completion velocity:(CGFloat)velocity { + self.isPresented = false; + [self.view endEditing:YES]; + [self + animateDismiss:^{ + self.containerView.frame = + CGRectMake(0, self.view.bounds.size.height, self.containerView.bounds.size.width, + self.containerView.bounds.size.height); + self.backdrop.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.0]; + } + velocity:velocity + completion:^(BOOL finished) { + [self dismissViewControllerAnimated:NO completion:completion]; + }]; +} + +- (void)setSheetBackgroundColor:(UIColor *)backgroundColor { + self.containerView.backgroundColor = backgroundColor; +} + +#pragma mark - Private Methods + +- (void)setupCornerRadius { + CGFloat radius = (CGFloat)[self.cornerTopRadius floatValue]; + UIBezierPath *maskPath = + [UIBezierPath bezierPathWithRoundedRect:self.containerView.bounds + byRoundingCorners:(UIRectCornerTopLeft | UIRectCornerTopRight) + cornerRadii:CGSizeMake(radius, radius)]; + + CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init]; + maskLayer.frame = self.containerView.bounds; + maskLayer.path = maskPath.CGPath; + + self.containerView.layer.mask = maskLayer; +} + +- (void)setup { + self.view.backgroundColor = [UIColor clearColor]; + + self.backdrop = [[UIView alloc] init]; + self.backdrop.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.0]; + [self.view addSubview:self.backdrop]; + + self.containerView = [[UIView alloc] init]; + self.containerView.backgroundColor = [UIColor whiteColor]; + self.containerView.layer.masksToBounds = YES; + [self.view addSubview:self.containerView]; + + [self addChildViewController:_boundViewController]; + + [self.containerView addSubview:_boundViewController.view]; + [_boundViewController didMoveToParentViewController:self]; + + self.tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(tapGestureHandler)]; + [self.backdrop addGestureRecognizer:self.tapGesture]; + + self.panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self + action:@selector(panGestureHandler:)]; + [self.containerView addGestureRecognizer:self.panGesture]; +} + +- (void)tapGestureHandler { + if (!self.isAnimatePresentInProcess && self.isPresented) { + [self dismiss]; + } +} + +- (void)findContentElements:(void (^)(UIView *header, UIView *content, UIView *footer))completion { + RNNReactView *reactView = _boundViewController.reactView; + dispatch_async(reactView.bridge.uiManager.methodQueue, ^{ + [reactView.bridge.uiManager + addUIBlock:^(RCTUIManager *uiManager, NSDictionary *viewRegistry) { + UIView *header = [uiManager viewForNativeID:NavigationLayoutElementHeaderID + withRootTag:reactView.reactTag]; + UIView *content = [uiManager viewForNativeID:NavigationLayoutElementContentID + withRootTag:reactView.reactTag]; + UIView *footer = [uiManager viewForNativeID:NavigationLayoutElementFooterID + withRootTag:reactView.reactTag]; + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(header, content, footer); + }); + }]; + }); +} + +- (void)scrollViewPanGestureHandler:(UIPanGestureRecognizer *)recognizer { + if (!self.rctScrollView.scrollView) { + return; + } + + CGFloat yTranslation = [recognizer translationInView:self.rctScrollView.scrollView].y; + CGFloat yVelocity = [recognizer velocityInView:self.rctScrollView.scrollView].y; + + BOOL isScrollOnTop = self.rctScrollView.scrollView.contentOffset.y <= 0; + + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + if (isScrollOnTop) { + self.rctScrollView.scrollView.contentOffset = CGPointZero; + } + break; + + case UIGestureRecognizerStateChanged: + if (!self.isDragging && isScrollOnTop) { + self.isDragging = YES; + self.startTranslationOffset = yTranslation; + } + + if (self.isDragging) { + CGFloat offset = yTranslation - self.startTranslationOffset; + if (offset < 0) { + self.isDragging = NO; + [self didEndDragging:offset velocity:yVelocity]; + + } else { + self.rctScrollView.scrollView.contentOffset = CGPointZero; + [self didDragWithOffset:offset]; + } + } + break; + + case UIGestureRecognizerStateEnded: + if (self.isDragging) { + self.isDragging = NO; + [self didEndDragging:yTranslation velocity:yVelocity]; + } + break; + + default: + break; + } +} + +- (void)panGestureHandler:(UIPanGestureRecognizer *)recognizer { + CGFloat offset = [recognizer translationInView:recognizer.view].y; + CGFloat velocity = [recognizer velocityInView:recognizer.view].y; + if (recognizer.state == UIGestureRecognizerStateChanged) { + [self didDragWithOffset:offset]; + } else if (recognizer.state == UIGestureRecognizerStateEnded) { + [self didEndDragging:offset velocity:velocity]; + } else if (recognizer.state == UIGestureRecognizerStateCancelled || + recognizer.state == UIGestureRecognizerStateFailed) { + [self didFailedDrag]; + } +} + +- (void)didDragWithOffset:(CGFloat)offset { + CGFloat maxContentHeight = [self contentMaximumHeight]; + if (offset < 0) { + if (self.containerFrame.size.height <= maxContentHeight) { + _delta = (sqrt(1 + (offset * -1)) * 2.5) * -1; + self.containerView.frame = CGRectMake( + self.containerFrame.origin.x, self.containerFrame.origin.y + _delta, + self.containerFrame.size.width, self.containerFrame.size.height - _delta); + } + } else { + self.containerView.frame = + CGRectMake(self.containerFrame.origin.x, self.containerFrame.origin.y + offset, + self.containerFrame.size.width, self.containerFrame.size.height); + + CGFloat minX = 0; + CGFloat maxX = maxContentHeight; + CGFloat minY = self.backdropOpacity; + CGFloat maxY = 0.0; + + CGFloat alpha = (offset - minX) * ((maxY - minY) / (maxX - minX)) + minY; + self.backdrop.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:alpha]; + } +} + +- (void)didEndDragging:(CGFloat)offset velocity:(CGFloat)velocity { + CGFloat destination = offset + 0.18 * velocity; + CGFloat contentCenter = self.containerFrame.size.height / 2; + if (destination > contentCenter) { + [self dismiss]; + } else { + CGRect newFrame = self.containerFrame; + if (_delta < 0) { + newFrame = CGRectMake(0, self.containerFrame.origin.y, self.containerFrame.size.width, + self.containerFrame.size.height - _delta); + _delta = 0; + } + + [self + animateDraggingWithCompletion:^{ + self.containerView.frame = newFrame; + } + completion:^(BOOL finished) { + self.containerView.frame = self.containerFrame; + }]; + } +} + +- (void)didFailedDrag { + [self animateDragging:^{ + self.containerView.frame = self.containerFrame; + }]; +} + +- (void)animateDragging:(void (^)(void))animations { + [self animate:animations velocity:0.0 completion:nil]; +} + +- (void)animateDraggingWithVelocity:(CGFloat)velocity animation:(void (^)(void))animations { + [self animate:animations velocity:velocity completion:nil]; +} + +- (void)animateDraggingWithCompletion:(void (^)(void))animations + completion:(void (^__nullable)(BOOL finished))completion { + [self animate:animations velocity:0.0 completion:completion]; +} + +- (void)animate:(void (^)(void))animations + velocity:(CGFloat)velocity + completion:(void (^__nullable)(BOOL finished))completion { + CGFloat damping = 500.0; + CGFloat stiffness = 1000.0; + CGFloat mass = 3.0; + CGFloat dampingRatio = damping / (2 * sqrt(stiffness * mass)); + + [UIView animateWithDuration:0.38 + delay:0 + usingSpringWithDamping:dampingRatio + initialSpringVelocity:velocity / 100 + options:UIViewAnimationOptionCurveEaseInOut | + UIViewAnimationOptionAllowUserInteraction + animations:animations + completion:completion]; +} + +- (void)animateDismiss:(void (^)(void))animations + velocity:(CGFloat)velocity + completion:(void (^__nullable)(BOOL finished))completion { + CGFloat damping = 500.0; + CGFloat stiffness = 1000.0; + CGFloat mass = 3.0; + CGFloat dampingRatio = damping / (2 * sqrt(stiffness * mass)); + + [UIView animateWithDuration:0.38 + delay:0 + usingSpringWithDamping:dampingRatio + initialSpringVelocity:0.0 + options:UIViewAnimationOptionCurveEaseIn | + UIViewAnimationOptionAllowUserInteraction + animations:animations + completion:completion]; +} + +- (void)dealloc { + if (self.rctScrollView) { + [self.rctScrollView.scrollView removeObserver:self forKeyPath:@"contentSize"]; + } + + if (self.contentView) { + [self.contentView removeObserver:self forKeyPath:@"frame"]; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +@end diff --git a/lib/ios/ReactNativeNavigation.xcodeproj/project.pbxproj b/lib/ios/ReactNativeNavigation.xcodeproj/project.pbxproj index edfbb4cd024..a5dd0389335 100644 --- a/lib/ios/ReactNativeNavigation.xcodeproj/project.pbxproj +++ b/lib/ios/ReactNativeNavigation.xcodeproj/project.pbxproj @@ -450,6 +450,8 @@ 9FDA2AC024F2A43B005678CC /* RCTConvert+SideMenuOpenGestureMode.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FDA2ABF24F2A43B005678CC /* RCTConvert+SideMenuOpenGestureMode.m */; }; A7626BFD1FC2FB2C00492FB8 /* RNNTopBarOptions.m in Sources */ = {isa = PBXBuildFile; fileRef = A7626BFC1FC2FB2C00492FB8 /* RNNTopBarOptions.m */; }; A7626C011FC5796200492FB8 /* RNNBottomTabsOptions.m in Sources */ = {isa = PBXBuildFile; fileRef = A7626C001FC5796200492FB8 /* RNNBottomTabsOptions.m */; }; + AC507A2D2C38BC6700E8DB18 /* RNNSheetViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = AC507A2B2C38BC6600E8DB18 /* RNNSheetViewController.h */; }; + AC507A2E2C38BC6700E8DB18 /* RNNSheetViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = AC507A2C2C38BC6700E8DB18 /* RNNSheetViewController.m */; }; B8415310251E07A600467F37 /* LinearInterpolator.m in Sources */ = {isa = PBXBuildFile; fileRef = B841530F251E07A600467F37 /* LinearInterpolator.m */; }; B841531D251E088100467F37 /* OvershootInterpolator.m in Sources */ = {isa = PBXBuildFile; fileRef = B8415316251E088100467F37 /* OvershootInterpolator.m */; }; B841531F251E088100467F37 /* SpringInterpolator.m in Sources */ = {isa = PBXBuildFile; fileRef = B8415318251E088100467F37 /* SpringInterpolator.m */; }; @@ -1000,6 +1002,8 @@ A7626BFE1FC2FB6700492FB8 /* RNNTopBarOptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNNTopBarOptions.h; sourceTree = ""; }; A7626BFF1FC578AB00492FB8 /* RNNBottomTabsOptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNNBottomTabsOptions.h; sourceTree = ""; }; A7626C001FC5796200492FB8 /* RNNBottomTabsOptions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNNBottomTabsOptions.m; sourceTree = ""; }; + AC507A2B2C38BC6600E8DB18 /* RNNSheetViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RNNSheetViewController.h; sourceTree = ""; }; + AC507A2C2C38BC6700E8DB18 /* RNNSheetViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNNSheetViewController.m; sourceTree = ""; }; B841530F251E07A600467F37 /* LinearInterpolator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LinearInterpolator.m; sourceTree = ""; }; B8415316251E088100467F37 /* OvershootInterpolator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OvershootInterpolator.m; sourceTree = ""; }; B8415318251E088100467F37 /* SpringInterpolator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SpringInterpolator.m; sourceTree = ""; }; @@ -1724,6 +1728,8 @@ D8AFADB41BEE6F3F00A4592D = { isa = PBXGroup; children = ( + AC507A2B2C38BC6600E8DB18 /* RNNSheetViewController.h */, + AC507A2C2C38BC6700E8DB18 /* RNNSheetViewController.m */, B841530E251E078000467F37 /* Interpolators */, E5F6C39B22DB4CB90093C2CE /* Utils */, 214545271F4DC7ED006E8DA1 /* Helpers */, @@ -1949,6 +1955,7 @@ E8DA24401F97459B00CD552B /* RNNElementFinder.h in Headers */, 509416A723A11C630036092C /* EnumParser.h in Headers */, 50AB0B1C2255F8640039DAED /* UIViewController+LayoutProtocol.h in Headers */, + AC507A2D2C38BC6700E8DB18 /* RNNSheetViewController.h in Headers */, 50D031342005149000386B3D /* RNNOverlayManager.h in Headers */, 50E5F7952240EBD6002AFEAD /* RNNAnimationsTransitionDelegate.h in Headers */, 50F72E552607468C0096758A /* PathTransition.h in Headers */, @@ -2256,6 +2263,7 @@ 503A8A1A23BCB2ED0094D1C4 /* RNNReactButtonView.m in Sources */, 50570BEB2063E09B006A1B5C /* RNNTitleViewHelper.m in Sources */, 50DD9155274FC6E200B4C917 /* AnimationObserver.m in Sources */, + AC507A2E2C38BC6700E8DB18 /* RNNSheetViewController.m in Sources */, 263905E71E4CAC950023D7D3 /* RNNSideMenuChildVC.m in Sources */, 5082CC3423CDC3B800FD2B6A /* HorizontalTranslationTransition.m in Sources */, 50495957216F6B3D006D2B81 /* DictionaryParser.m in Sources */, diff --git a/lib/src/Navigation.ts b/lib/src/Navigation.ts index 2cdcf54a8cf..183423930b3 100644 --- a/lib/src/Navigation.ts +++ b/lib/src/Navigation.ts @@ -164,6 +164,18 @@ export class NavigationRoot { ); } + /** + * Send event for start presentatioin sheet + */ + public setupSheetContentNodes( + componentId: string, + headerNode?: number | null, + contentNode?: number | null, + footerNode?: number | null + ) { + return this.commands.setupSheetContentNodes(componentId, headerNode, contentNode, footerNode); + } + /** * Reset the app to a new layout */ diff --git a/lib/src/NavigationDelegate.ts b/lib/src/NavigationDelegate.ts index 2c27a0f62b9..157c758126d 100644 --- a/lib/src/NavigationDelegate.ts +++ b/lib/src/NavigationDelegate.ts @@ -1,5 +1,5 @@ import { EventsRegistry } from './events/EventsRegistry'; -import { ComponentProvider } from 'react-native'; +import { ComponentProvider, Platform, findNodeHandle } from 'react-native'; import { NavigationConstants } from './adapters/Constants'; import { LayoutRoot, Layout } from './interfaces/Layout'; import { Options } from './interfaces/Options'; @@ -10,6 +10,7 @@ import { NavigationRoot } from './Navigation'; import { NativeCommandsSender } from './adapters/NativeCommandsSender'; import { NativeEventsReceiver } from './adapters/NativeEventsReceiver'; import { AppRegistryService } from './adapters/AppRegistryService'; +import { RefObject } from 'react'; export class NavigationDelegate { private concreteNavigation: NavigationRoot; @@ -89,6 +90,45 @@ export class NavigationDelegate { ); } + /** + * Send event for start presentatioin sheet + */ + public setupSheetContentNodes( + componentId: string, + headerRef?: RefObject, + contentRef?: RefObject, + footerRef?: RefObject + ) { + if (contentRef === undefined) { + throw new Error('Cannot present sheet, because contentRef is undefined'); + } + + const headerNode = headerRef ? findNodeHandle(headerRef.current) : null; + const contentNode = contentRef ? findNodeHandle(contentRef.current) : null; + const footerNode = footerRef ? findNodeHandle(footerRef.current) : null; + + if (Platform.OS === 'android') { + if (headerRef?.current) { + headerRef.current.setNativeProps({ nativeID: `SheetHeader-${headerNode}` }); + } + + if (footerRef?.current) { + footerRef.current.setNativeProps({ nativeID: `SheetFooter-${footerNode}` }); + } + + if (contentRef?.current) { + contentRef.current.setNativeProps({ nativeID: `SheetContent-${contentNode}` }); + } + } + + this.concreteNavigation.setupSheetContentNodes( + componentId, + headerNode, + contentNode, + footerNode + ); + } + /** * Reset the app to a new layout */ diff --git a/lib/src/adapters/NativeCommandsSender.ts b/lib/src/adapters/NativeCommandsSender.ts index f181b9e263f..371bb56e223 100644 --- a/lib/src/adapters/NativeCommandsSender.ts +++ b/lib/src/adapters/NativeCommandsSender.ts @@ -19,6 +19,12 @@ interface NativeCommandsModule { getLaunchArgs(commandId: string): Promise; getNavigationConstants(): Promise; getNavigationConstantsSync(): NavigationConstants; + setupSheetContentNodes( + componentId: string, + headerNode: number | null, + contentNode: number | null, + footerNode: number | null + ): Promise; } export class NativeCommandsSender { @@ -94,4 +100,18 @@ export class NativeCommandsSender { getNavigationConstantsSync() { return this.nativeCommandsModule.getNavigationConstantsSync(); } + + setupSheetContentNodes( + componentId: string, + headerNode?: number | null, + contentNode?: number | null, + footerNode?: number | null + ) { + this.nativeCommandsModule.setupSheetContentNodes( + componentId, + headerNode || 0, + contentNode || 0, + footerNode || 0 + ); + } } diff --git a/lib/src/commands/Commands.ts b/lib/src/commands/Commands.ts index 8d6feb39720..cc34a047861 100644 --- a/lib/src/commands/Commands.ts +++ b/lib/src/commands/Commands.ts @@ -27,6 +27,20 @@ export class Commands { private readonly optionsCrawler: OptionsCrawler ) {} + public setupSheetContentNodes( + componentId: string, + headerNode?: number | null, + contentNode?: number | null, + footerNode?: number | null + ) { + return this.nativeCommandsSender.setupSheetContentNodes( + componentId, + headerNode, + contentNode, + footerNode + ); + } + public setRoot(simpleApi: LayoutRoot) { const input = cloneLayout(simpleApi); this.optionsCrawler.crawl(input.root); diff --git a/lib/src/commands/LayoutTreeParser.ts b/lib/src/commands/LayoutTreeParser.ts index 57d75350b6c..412c6fc8fb8 100644 --- a/lib/src/commands/LayoutTreeParser.ts +++ b/lib/src/commands/LayoutTreeParser.ts @@ -9,6 +9,7 @@ import { LayoutSideMenu, LayoutSplitView, ExternalComponent, + Sheet, } from '../interfaces/Layout'; import { UniqueIdProvider } from '../adapters/UniqueIdProvider'; @@ -32,10 +33,25 @@ export class LayoutTreeParser { return this.externalComponent(api.externalComponent); } else if (api.splitView) { return this.splitView(api.splitView); + } else if (api.sheet) { + return this.sheet(api.sheet); } throw new Error(`unknown LayoutType "${Object.keys(api)}"`); } + private sheet(api: Sheet): LayoutNode { + return { + id: api.id || this.uniqueIdProvider.generate(LayoutType.Sheet), + type: LayoutType.Sheet, + data: { + name: api.name.toString(), + options: api.options, + passProps: api.passProps, + }, + children: [], + }; + } + private topTabs(api: LayoutTopTabs): LayoutNode { return { id: api.id || this.uniqueIdProvider.generate(LayoutType.TopTabs), diff --git a/lib/src/commands/LayoutType.ts b/lib/src/commands/LayoutType.ts index 34df2240dbd..0b3ef149100 100644 --- a/lib/src/commands/LayoutType.ts +++ b/lib/src/commands/LayoutType.ts @@ -9,4 +9,5 @@ export enum LayoutType { TopTabs = 'TopTabs', ExternalComponent = 'ExternalComponent', SplitView = 'SplitView', + Sheet = 'Sheet', } diff --git a/lib/src/interfaces/Layout.ts b/lib/src/interfaces/Layout.ts index 076cb56c9c0..fcb7bc7683c 100644 --- a/lib/src/interfaces/Layout.ts +++ b/lib/src/interfaces/Layout.ts @@ -1,5 +1,24 @@ import { Options } from './Options'; +export interface Sheet { + /** + * Set the screen's id so Navigation.mergeOptions can be used to update options + */ + id?: string; + /** + * Name of your component + */ + name: string | number; + /** + * Configure component options + */ + options?: Options; + /** + * Properties to pass down to the component + */ + passProps?: any; +} + export interface LayoutComponent

{ /** * Component reference id, Auto generated if empty @@ -169,6 +188,11 @@ export interface ExternalComponent { } export interface Layout

{ + /** + * Sheet + */ + sheet?: Sheet; + /** * Set the component */ diff --git a/lib/src/interfaces/Options.ts b/lib/src/interfaces/Options.ts index 1ab5b4ef521..81329d19ec3 100644 --- a/lib/src/interfaces/Options.ts +++ b/lib/src/interfaces/Options.ts @@ -200,6 +200,22 @@ export interface OptionsLayout { * #### (Android specific) */ adjustResize?: boolean; + + /** + * Set fullScreen for sheet, this for ignore calc size for sheet + * #### (Android specific) + */ + sheetFullScreen?: boolean; + + /** + * Set sheet backdrop opacity + */ + sheetBackdropOpacity?: number; + + /** + * Set sheet corner top radius + */ + sheetBorderTopRadius?: number; } export enum OptionsModalPresentationStyle { diff --git a/package.json b/package.json index e56a32a0d81..65afce29325 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@types/hoist-non-react-statics": "^3.0.1", "@types/jasmine": "3.5.10", "@types/jest": "27.0.2", - "@types/lodash": "^4.14.149", + "@types/lodash": "4.14.149", "@types/react": "16.9.41", "@types/react-native": "0.63.1", "@types/react-test-renderer": "16.9.2", @@ -115,7 +115,7 @@ "react-native": "0.73.3", "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "^2.10.1", - "react-native-reanimated": "^3.8.1", + "react-native-reanimated": "3.8.1", "react-native-ui-lib": "7.3.6", "react-redux": "5.x.x", "react-test-renderer": "18.2.0", diff --git a/playground/android/app/build.gradle b/playground/android/app/build.gradle index 8624bb3cc60..5653c1bd679 100644 --- a/playground/android/app/build.gradle +++ b/playground/android/app/build.gradle @@ -49,7 +49,7 @@ android { } dependencies { - implementation 'com.google.android.material:material:1.4.0' + implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.appcompat:appcompat:1.3.1' implementation("com.facebook.react:react-android") diff --git a/playground/ios/playground.xcodeproj/project.pbxproj b/playground/ios/playground.xcodeproj/project.pbxproj index 195a51e534b..10dfc7b401e 100644 --- a/playground/ios/playground.xcodeproj/project.pbxproj +++ b/playground/ios/playground.xcodeproj/project.pbxproj @@ -1487,16 +1487,13 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", - "-DRN_FABRIC_ENABLED", + " ", ); OTHER_CPLUSPLUSFLAGS = ( "$(inherited)", - "-DRN_FABRIC_ENABLED", - ); - OTHER_LDFLAGS = ( - "-Wl", - "-ld_classic", + " ", ); + OTHER_LDFLAGS = ""; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -1548,16 +1545,13 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = ( "$(inherited)", - "-DRN_FABRIC_ENABLED", + " ", ); OTHER_CPLUSPLUSFLAGS = ( "$(inherited)", - "-DRN_FABRIC_ENABLED", - ); - OTHER_LDFLAGS = ( - "-Wl", - "-ld_classic", + " ", ); + OTHER_LDFLAGS = ""; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/playground/src/commons/Layouts.ts b/playground/src/commons/Layouts.ts index cb2337573ce..bf65b6649d0 100644 --- a/playground/src/commons/Layouts.ts +++ b/playground/src/commons/Layouts.ts @@ -1,6 +1,7 @@ -import { Options, Layout } from 'react-native-navigation'; +import { Options, Layout, OptionsModalPresentationStyle } from 'react-native-navigation'; import isString from 'lodash/isString'; import isArray from 'lodash/isArray'; +import { Platform } from 'react-native'; type CompIdOrLayout = string | Layout; @@ -20,4 +21,40 @@ const component =

( : (compIdOrLayout as Layout

); }; -export { stack, component }; +const sheet =

( + compIdOrLayout: CompIdOrLayout, + options?: Options, + passProps?: P +): Layout

=> { + return isString(compIdOrLayout) + ? { + sheet: { + name: compIdOrLayout, + passProps, + options: { + ...options, + layout: { + componentBackgroundColor: '#FFF', + ...options?.layout, + }, + modalPresentationStyle: + Platform.OS === 'android' + ? OptionsModalPresentationStyle.overCurrentContext + : OptionsModalPresentationStyle.overFullScreen, + animations: { + showModal: { + enter: { enabled: false }, + exit: { enabled: false }, + }, + dismissModal: { + enter: { enabled: false }, + exit: { enabled: false }, + }, + }, + }, + }, + } + : (compIdOrLayout as Layout

); +}; + +export { stack, component, sheet }; diff --git a/playground/src/screens/NavigationScreen.tsx b/playground/src/screens/NavigationScreen.tsx index f188dcdd430..4f1876752a1 100644 --- a/playground/src/screens/NavigationScreen.tsx +++ b/playground/src/screens/NavigationScreen.tsx @@ -60,6 +60,14 @@ export default class NavigationScreen extends NavigationComponent {