kirillzyusko / react-native-keyboard-controller

Keyboard manager which works in identical way on both iOS and Android
https://kirillzyusko.github.io/react-native-keyboard-controller/
MIT License
1.71k stars 73 forks source link

KeyboardAwareScrollView: justifyContent: "space-between" is not working properly in contentContainerStyle #645

Open Isaccobosio opened 1 week ago

Isaccobosio commented 1 week ago

Describe the bug Configuring the property contentContainerStyle for KeyboardAwareScrollView with justifyContent: "space-between" does not work as expected. If I do the same thing with ScrollView it works fine.

Code snippet

<KeyboardAwareScrollView
      style={{ backgroundColor: "green" }}
      contentContainerStyle={{
        flexGrow: 1,
        backgroundColor: "blue",
        justifyContent: "space-between",
      }}
    >
      <View style={{ height: 100, backgroundColor: "red" }} />
      <View style={{ height: 100, backgroundColor: "red" }} />
</KeyboardAwareScrollView>

Expected behavior As a Scrollview, I expect that the two View inside the KeyboardAwareScrollView is positioned with a space between them. What I am trying to create is a screen in the upper part of which there is a login form and in the lower part buttons for logging in with the various societies. If the screen size is not large enough, the two main components will have to scroll.

Screenshots With KeyboardAwareScrollView With ScrollView (expected)
image image

Smartphone

Additional context I also tried to put a View between the two main Views like this:

<KeyboardAwareScrollView
      style={{ backgroundColor: "green" }}
      contentContainerStyle={{
        flexGrow: 1,
        backgroundColor: "blue",
        justifyContent: "space-between",
      }}
    >
      <View style={{ height: 100, backgroundColor: "red" }} />
      <View style={{ flex: 1 }} />
      <View style={{ height: 100, backgroundColor: "red" }} />
</KeyboardAwareScrollView>

But this is the result:

image

As you can see in the bottom of the screen there is a little blue space (maybe a pixel). This means that the two Views are not displayed correctly. Is like a ghost View is displayed:

image
Isaccobosio commented 1 week ago

Inside node_modules/react-native-keyboard-controller/src/components/KeyboardAwareScrollView/index.tsx there is this piece of code:

const view = useAnimatedStyle(
      () =>
        enabled
          ? {
              // animations become choppy when scrolling to the end of the `ScrollView` (when the last input is focused)
              // this happens because the layout recalculates on every frame. To avoid this we slightly increase padding
              // by `+1`. In this way we assure, that `scrollTo` will never scroll to the end, because it uses interpolation
              // from 0 to `keyboardHeight`, and here our padding is `keyboardHeight + 1`. It allows us not to re-run layout
              // re-calculation on every animation frame and it helps to achieve smooth animation.
              // see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342
              paddingBottom: currentKeyboardFrameHeight.value + 1,
            }
          : {},
      [enabled],
    );

    return (
      <ScrollViewComponent
        ref={onRef}
        {...rest}
        scrollEventThrottle={16}
        onLayout={onScrollViewLayout}
      >
        {children}
        <Reanimated.View style={view} />
      </ScrollViewComponent>
    );

The <Reanimated.View style={view} /> is the problem I think. But there is no way to style it and there is also no way to delete it.

kirillzyusko commented 1 week ago

Hi @Isaccobosio

Thank you for raising this problem πŸ™Œ I'll split my answer in two parts:

1️⃣ Why <Reanimated.View style={view} /> is needed?

<Reanimated.View style={view} /> is needed to add empty space to make sure you can scroll down and the content will be shown above the keyboard (even if keyboard is shown).

And of course, since I'm placing an additional view it'll affect the layout of your component.

As you pointed out above - you can add a view with flex: 1 or you can wrap your two view inside additional view and specify minHeight:

<KeyboardAwareScrollView
      style={{ backgroundColor: "green" }}
      contentContainerStyle={{
        flexGrow: 1,
        backgroundColor: "blue",
        justifyContent: "space-between",
      }}
    >
    <View style={{minHeight: screenHeight- safeArea.top - headerHeight}}>
      <View style={{ height: 100, backgroundColor: "red" }} />
      <View style={{ height: 100, backgroundColor: "red" }} />
   </View>
</KeyboardAwareScrollView>

I haven't tested code above - just wanted to show the idea.

2️⃣ Why version from APSL was working without any problems before?

Version from APSL was relying on contentInset implementation. This code can add spacing without affecting the YOGA-layout.

Ideally I also would like to re-use the implementation of contentInset, but the problem is that it's only iOS specific property and it doesn't have any effect on Android πŸ˜” So on Android I still would have to use the code that I'm using at the moment.

So I thought that cross-platform standart is more important in this library and decided to use cross-platform code everywhere (so the bug will be present everywhere as well, not only on Android or iOS).


Do you have any ideas on how to fix this problem in your project? Given the fact, that it's impossible to remove that view (I explained above why).

Isaccobosio commented 1 week ago

Hi @kirillzyusko , thank you very much for the answer!

The situation is clear. I understand that the Reanimated.View is essential for the purpose of this library. At this moment I don't have any solutions beside the one with the View and flex: 1.

What about to set the Reanimated.View with an initial height of 0? I haven't test it but it may helps.

Anyway, with <View style={{ flex: 1 }} /> the problem is solved. My problem is that I'm using your component as a LayoutComponent and I'm currently passing a children. Putting a <View style={{ flex: 1 }} /> seems like a workaround.

kirillzyusko commented 1 week ago

What about to set the Reanimated.View with an initial height of 0? I haven't test it but it may helps.

Do you want it to be like currentKeyboardFrameHeight.value === 0 ? 0 : currentKeyboardFrameHeight.value + 1?

Putting a <View style={{ flex: 1 }} /> seems like a workaround.

Well, definitely yes πŸ˜” Can you try to use the code that I gave to you (where you specify minHeight for container)? In this case (if it works) you can pass children as you did before πŸ‘€

Isaccobosio commented 1 week ago

Do you want it to be like currentKeyboardFrameHeight.value === 0 ? 0 : currentKeyboardFrameHeight.value + 1?

I don't know if this might be a solution. I need to try it. I can try it later and I'll let you know for sure!

Can you try to use the code that I gave to you (where you specify minHeight for container)?

It could work! I can try this as well πŸ‘πŸ»

kirillzyusko commented 1 week ago

Yeah, please test and let me know how it works πŸ™Œ

Isaccobosio commented 6 days ago

Hey @kirillzyusko

Sorry for the late answer but it was a hard week. So, I did some test.

First test: use the minHeight strategy.

Code

const LoginScreen: React.FC = () => {
  const screenHeight = Dimensions.get("window").height;
  const headerHeight = useHeaderHeight();
  const safeArea = useSafeAreaInsets();

  return (
    <KeyboardAwareScrollView
      style={{ backgroundColor: "purple" }}
      contentContainerStyle={{
        flexGrow: 1,
        backgroundColor: "blue",
        // justifyContent: "space-between", // not needed anymore
        paddingBottom: safeArea.bottom,
      }}
    >
      <View
        style={{
          minHeight: screenHeight - safeArea.bottom - headerHeight,
          backgroundColor: "yellow",
          justifyContent: "space-between",
        }}
      >
        <View style={{ height: 100, backgroundColor: "red" }} />
        <View style={{ height: 100, backgroundColor: "green" }} />
      </View>
    </KeyboardAwareScrollView>
  );
}

Result

https://github.com/user-attachments/assets/d563cf2f-36e3-4d27-8a4d-242a26c86ee9

This is working as expected, that means that it could be a solution. But I had to add some more style to the wrapper View.

You suggested to put <View style={{minHeight: screenHeight- safeArea.top - headerHeight}}> and I edited that with this:

 <View
        style={{
          minHeight: screenHeight - safeArea.bottom - headerHeight,
          backgroundColor: "yellow",
          justifyContent: "space-between",
        }}
      >
        {...}
</View>

I had to do like that because without justifyContent: "space-between" the childrens are not in the correct position. So basically the justifyContent: "space-between" inside the contentContainerStyle is no more needed.

without the space between with the space between
image image

Second test: currentKeyboardFrameHeight.value condition

What I found is that setting the height of the Reanimated View based on currentKeyboardFrameHeight value has no effect. Even if a View has a height set to 0, it is part of the parent View. So I tried something like this

const view = useAnimatedStyle(
      () =>
        enabled
          ? {
            // animations become choppy when scrolling to the end of the `ScrollView` (when the last input is focused)
            // this happens because the layout recalculates on every frame. To avoid this we slightly increase padding
            // by `+1`. In this way we assure, that `scrollTo` will never scroll to the end, because it uses interpolation
            // from 0 to `keyboardHeight`, and here our padding is `keyboardHeight + 1`. It allows us not to re-run layout
            // re-calculation on every animation frame and it helps to achieve smooth animation.
            // see: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/342
            paddingBottom: currentKeyboardFrameHeight.value + 1,
            display: currentKeyboardFrameHeight.value === 0 ? "none" : "flex",
          }
          : {},
      [enabled],
    );

    return (
      <ScrollViewComponent
        ref={onRef}
        {...rest}
        scrollEventThrottle={16}
        onLayout={onScrollViewLayout}
      >
        {children}
        <Reanimated.View style={view} />
      </ScrollViewComponent>
    );

With this approach it works because it effectively dismount the reanimated view. But as soon as the keyboard is presented it "flicker". Video down below

https://github.com/user-attachments/assets/067b6120-c446-4217-84d9-ae53fdca6887

Conclusion

I don't know how to handle this situation and which is the best solution. I hope that you have better ideas!

cheers!

kirillzyusko commented 3 days ago

Sorry for a late response @Isaccobosio

So it looks like first approach is actually working for you? πŸ€”

Regarding second solution - I think you can interpolate paddingBottom to have a smooth transition without a flicker, i. e.:

const keyboardFrame = interpolate(
          e.height,
          [0, keyboardHeight.value],
          [0, keyboardHeight.value + extraKeyboardSpace + 1],
        );

        currentKeyboardFrameHeight.value = keyboardFrame;

Would you mind to check that solution? I think it shouldn't hurt performance a lot (let me know if this solution works for you and then I can check on a low end device the FPS) 😊