react-native-keyboard-controller icon indicating copy to clipboard operation
react-native-keyboard-controller copied to clipboard

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

Open VladyslavMartynov10 opened this issue 1 year ago • 2 comments

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:

https://github.com/kirillzyusko/react-native-keyboard-controller/assets/115457344/b8c8a974-35d9-44f6-8151-d03b5e63cc1a

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 🙂

VladyslavMartynov10 avatar Jan 27 '24 16:01 VladyslavMartynov10

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?

kirillzyusko avatar Jan 31 '24 19:01 kirillzyusko

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.

VladyslavMartynov10 avatar Jan 31 '24 20:01 VladyslavMartynov10