Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Bottom Sheet component compatibility/custom native wrapper(long-term feature) #343

Closed
VladyslavMartynov10 opened this issue Jan 27, 2024 · 4 comments · Fixed by #445
Closed
Assignees
Labels
sponsor 💖 Someone pays money for the issue to be resolved 💸

Comments

@VladyslavMartynov10
Copy link

VladyslavMartynov10 commented Jan 27, 2024

Hey @kirillzyusko !

I was exploring potential improvements for react-native-keyboard-controller and discovered that the package doesn't currently support the popular BottomSheet component(without any additional tricks).

Initially, I thought integrating with the BottomSheet package would be easier than it actually turned out to be. The library has its own pre-built component, BottomSheetInput, and offers additional options for modifying keyboard behavior.

The main challenge I faced was achieving a smooth transition for the BottomSheet between active and inactive keyboard states. Since the library manages the keyboard state based on the BottomSheet's react-native-reanimated state, it requires significant JavaScript boilerplate to make it work with react-native-keyboard-controller. I checked the input behavior to determine whether it was focused or not, and then simply adjusted the snapPoints state, which produced the desired effect.

On the other hand, after implementing a custom BottomSheet solution, I observed improved results:

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2024-01-27.at.18.20.32.mp4

Pasting quick implementation:

import React, { useCallback, useImperativeHandle } from "react";
import { Dimensions, StyleSheet } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
  Extrapolation,
  FadeIn,
  FadeOut,
  Layout,
  interpolate,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from "react-native-reanimated";

import { Backdrop } from "./Backdrop";

import type { StyleProp, ViewStyle } from "react-native";

const { height: SCREEN_HEIGHT } = Dimensions.get("window");

type BottomSheetProps = {
  children?: React.ReactNode;
  maxHeight?: number;
  style?: StyleProp<ViewStyle>;
  onClose?: () => void;
};

export type BottomSheetRef = {
  open: () => void;
  isActive: () => boolean;
  close: () => void;
};

export const BottomSheet = React.forwardRef<BottomSheetRef, BottomSheetProps>(
  ({ children, style, maxHeight = SCREEN_HEIGHT, onClose }, ref) => {
    const translateY = useSharedValue(maxHeight);

    const MAX_TRANSLATE_Y = -maxHeight;

    const active = useSharedValue(false);

    const scrollTo = useCallback((destination: number) => {
      "worklet";
      active.value = destination !== maxHeight;

      translateY.value = withSpring(destination, {
        mass: 0.4,
      });
    }, []);

    const close = useCallback(() => {
      "worklet";
      return scrollTo(maxHeight);
    }, [maxHeight, scrollTo]);

    useImperativeHandle(
      ref,
      () => ({
        open: () => {
          "worklet";
          scrollTo(0);
        },
        close,
        isActive: () => {
          return active.value;
        },
      }),
      [close, scrollTo, active.value],
    );

    const context = useSharedValue({ y: 0 });

    const gesture = Gesture.Pan()
      .onStart(() => {
        context.value = { y: translateY.value };
      })
      .onUpdate((event) => {
        if (event.translationY > -50) {
          translateY.value = event.translationY + context.value.y;
        }
      })
      .onEnd((event) => {
        if (event.translationY > 100) {
          if (onClose) {
            runOnJS(onClose)();
          } else close();
        } else {
          scrollTo(context.value.y);
        }
      });

    const animatedContainerStyle = useAnimatedStyle(() => {
      const borderRadius = interpolate(
        translateY.value,
        [MAX_TRANSLATE_Y + 50, MAX_TRANSLATE_Y],
        [25, 5],
        Extrapolation.CLAMP,
      );

      return {
        borderRadius,
        transform: [{ translateY: translateY.value }],
      };
    });

    return (
      <>
        <Backdrop onTap={onClose ?? close} isActive={active} />
        <GestureDetector gesture={gesture}>
          <Animated.View
            style={[styles.bottomSheetContainer, animatedContainerStyle, style]}
          >
            <Animated.View layout={Layout} entering={FadeIn} exiting={FadeOut}>
              {children}
            </Animated.View>
          </Animated.View>
        </GestureDetector>
      </>
    );
  },
);

const styles = StyleSheet.create({
  bottomSheetContainer: {
    backgroundColor: "#FFF",
    width: "95%",
    position: "absolute",
    bottom: 30,
  }
});
import React from "react";
import { StyleSheet } from "react-native";
import Animated, {
  useAnimatedProps,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated";

import type { ViewProps } from "react-native";
import type { AnimatedProps, SharedValue } from "react-native-reanimated";

type BackdropProps = {
  onTap: () => void;
  isActive: SharedValue<boolean>;
};

export const Backdrop: React.FC<BackdropProps> = React.memo(
  ({ isActive, onTap }) => {
    const animatedBackdropStyle = useAnimatedStyle(() => {
      return {
        opacity: withTiming(isActive.value ? 1 : 0),
      };
    }, []);

    const backdropProps = useAnimatedProps<AnimatedProps<ViewProps>>(() => {
      return {
        pointerEvents: isActive.value ? "auto" : "none",
      };
    }, []);

    return (
      <Animated.View
        onTouchStart={onTap}
        animatedProps={backdropProps}
        style={[
          {
            ...StyleSheet.absoluteFillObject,
            backgroundColor: "rgba(0,0,0,0.2)",
          },
          animatedBackdropStyle,
        ]}
      />
    );
  },
);
import React, { useCallback, useRef } from "react";
import { Button, StyleSheet, TextInput, View } from "react-native";
import {
  KeyboardAwareScrollView,
  KeyboardController,
} from "react-native-keyboard-controller";

import { BottomSheet } from "./BottomSheet";

import type { BottomSheetRef } from "./BottomSheet";

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "white",
    alignItems: "center",
    justifyContent: "center",
  },
  bottomSheetContainer: {
    backgroundColor: "#FFF",
    flex: 1,
    padding: 25,
  },
  input: {
    height: 50,
    width: "90%",
    borderWidth: 2,
    borderColor: "#3C3C3C",
    borderRadius: 8,
    alignSelf: "center",
    marginTop: 16,
  },
});

function BottomSheetScreen() {
  const ref = useRef<BottomSheetRef>(null);

  const close = useCallback(() => {
    ref.current?.close();

    KeyboardController.dismiss();
  }, []);

  const open = useCallback(() => {
    ref.current?.open();
  }, []);

  return (
    <View style={styles.container}>
      <Button title="Open BottomSheet" onPress={open} />

      <BottomSheet
        ref={ref}
        style={styles.bottomSheetContainer}
        onClose={close}
      >
        <KeyboardAwareScrollView showsVerticalScrollIndicator={false}>
          {new Array(5).fill(0).map((_, i) => (
            <TextInput
              key={i}
              placeholder={`TextInput#${i}`}
              keyboardType={i % 2 === 0 ? "numeric" : "default"}
              style={styles.input}
            />
          ))}
        </KeyboardAwareScrollView>
      </BottomSheet>
    </View>
  );
}

export default BottomSheetScreen;

Finally, it seems to me that the idea of creation own native iOS/Android Bottomsheet wrapper might be a more suitable solution in this scenario, rather than attempting to modify the behavior of an existing JavaScript library.

However, it seems that this approach is not the primary goal of the current library. Perhaps creating a new package would be a better course of action.

I would value your input on this issue, as deciding on this feature involves extensive consideration of its pros and cons. I've shared all the considerations and ideas that have been on my mind for the past three weeks 🙂

@github-actions github-actions bot added the sponsor 💖 Someone pays money for the issue to be resolved 💸 label Jan 27, 2024
@VladyslavMartynov10 VladyslavMartynov10 changed the title Bottom Sheet component compatibility/custom native wrapper(long-term feauture) Bottom Sheet component compatibility/custom native wrapper(long-term feature) Jan 30, 2024
@kirillzyusko
Copy link
Owner

Thank you @VladyslavMartynov10 and sorry for late response 😅

I think the integration is problematic because bottom-sheet is using own pre-defined keyboard movement interpolations, right? What happens if you disable keyboard handling from BottomSheet? Would it be possible then just to translate a bottom sheet by translateY using useReanimatedKeyboardAnimation, for example?

I think replication of BottomSheet is a pretty complex stuff and definetly I wouldn't like to have such components inside this library 😅 3rd party package sounds more reasonable for me 👍

But in ideal case both libraries should work together well, so first of all I'd like to understand which problems did you have trying to integrate bottom sheet and this library? Would you mind to describe it as precisely as possible?

@VladyslavMartynov10
Copy link
Author

VladyslavMartynov10 commented Jan 31, 2024

Hey @kirillzyusko ! No worries, I'm almost available at any time.

I agree that replicating a bottom-sheet is a challenging task, especially if we aim for a native implementation.

My interest in exploring potential integration originates from my personal experiments. It's important to note that currently, all third-party libraries are using React Native implementation, and we lack a native implementation alternative. Additionally, I've been particularly impressed with the NativeSheet implementation for iOS. This implementation excels, offering excellent functionality with minimal boilerplate required, especially when using the SwiftUI framework.

In my situation, I attempted to integrate a KeyboardToolBar component (which I presented to you for KeyboardToolbar feature) with gorhom/bottomsheet to achieve smooth transitions between focused inputs. From this starting point, I'm considering three options:

  • Disable the react-native-keyboard-controller module and use a pre-built component from gorhom/bottomsheet. This was my initial solution, but it presents some issues due to unsynchronized keyboard height.

  • Implement my own solution using react-native-reanimated, which is straightforward but doesn't address all potential scenarios. While it's a great option for experimentation, it requires a significant amount of code.

  • Utilize the react-native-keyboard-controller module to monitor the focus/unfocus state of inputs and adjust the translateY of BottomSheet accordingly.

Overall, I believe we should develop a practical example for others who might encounter this issue in the future. Also, thank you for the suggested idea. I need to review it further, as it seems to me that a custom interpolation approach should work.

Perhaps in the near future, we might be able to develop something similar to a native solution, although I'm aware that this would require considerable effort and thorough investigation.

@kirillzyusko
Copy link
Owner

@VladyslavMartynov10 I followed a guide that I wrote in #445 and it looks like it works pretty well:

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2024-05-14.at.18.01.52.mp4

Can I close this issue and merge this PR?

kirillzyusko added a commit that referenced this issue May 16, 2024
## 📜 Description

Added docs explaining how to integrate `KeyboardAwareScrollView` and
`@gorhom/bottom-sheet`.

## 💡 Motivation and Context

It may be no very obvious that you'll need to wrap it in additional
HOCs.

Inspired by
#309 (comment)

Closes
#343

## 📢 Changelog

<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->

### Docs

- added a section about integration `KeyboardAwareScrollView` with
`@gorhom/bottom-sheet`;

## 🤔 How Has This Been Tested?

Tested via preview and `localhost:3000`.

## 📸 Screenshots (if appropriate):

<img width="1300" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/e321a355-46fd-41ed-8f39-8a45a9f18c0d">

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
@kirillzyusko
Copy link
Owner

@VladyslavMartynov10 the issue got automatically closed because I merged PR. If you think that the issue is still present - let me know and I'll be happy to re-open this issue 😊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
sponsor 💖 Someone pays money for the issue to be resolved 💸
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants