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.75k stars 78 forks source link

KeyboardAwareScrollView: scroll only when needed #168

Open MarceloPrado opened 1 year ago

MarceloPrado commented 1 year ago

Describe the bug I'm trying to replicate the KeyboardAwareScrollView behavior fromreact-native-keyboard-aware-scroll-view. Using the KeyboardAwareScrollView found in /examples, I noticed the scroll view scrolls more than it needs to:

https://github.com/kirillzyusko/react-native-keyboard-controller/assets/8047841/ecd6d68a-7525-4972-9965-304352661e4b

Notice how once I press the input with the scroll begins here placeholder, it scrolls much further than it should. I'm trying to make the input align with my sticky action bar (it should be a fixed amount above the sticky bar).

Code snippet I used the code from your examples folder, with one modification:

const maybeScroll = useWorkletCallback(
  (e: number) => {
    "worklet";

    const visibleRect = height - keyboardHeight.value;

    if (visibleRect - click.value <= extraScrollHeight) {
      fakeViewHeight.value = e;

      const interpolatedScrollTo = interpolate(
        e,
        [0, keyboardHeight.value],
        [
          0,
          keyboardHeight.value - (height - click.value) + extraScrollHeight,
        ]
      );
      const targetScrollY =
        Math.max(interpolatedScrollTo, 0) + scrollPosition.value;

      scrollTo(scrollViewAnimatedRef, 0, targetScrollY, false);
    } else {
      fakeViewHeight.value = 0;
    }
  },
  [extraScrollHeight]
);

I added an if/else branch that skips scrolling when the click wouldn't overlap with the keyboard + bottom offset. Now, I need to figure out the right interpolation math that causes the view to scroll only by the right/minimum amount.

note: extraScrollHeight is a prop, similar to your BOTTOM_OFFSET.

Let me know if I can help with more details! I believe this would be a great addition to the component, since it enables a more seamless migration from react-native-keyboard-aware-scroll-view.

kirillzyusko commented 1 year ago

Hi @MarceloPrado

It seems like interpolatedScrollTo has incorrect values. In example app I didn't encounter such behaviour. Try to console.log the value and try to understand why it's bigger than expected :) The idea was to interpolate keyboardHeight to distance (like keyboard height is 200, but you'll need to scroll only 20px, so we interpolate 200 to 20).

I believe this would be a great addition to the component, since it enables a more seamless migration from react-native-keyboard-aware-scroll-view

Yes, I agree with it. I had a plan to include this component in the library (like an alternative to react-native-keyboard-aware-scroll-view), but with better animations. However I don't like the approach with capturing touch point on a screen - instead we should get coordinates of text input and I'm currently working on it. The near plan is to release 1.6.0 version with the support for synchronous calculation of layout in worklets and then release 1.7.0 with pre-bundled components (KeyboardAvoidingView/KeyboardAwareScrollView).

P. S. that's how KeyboardAwareScrollView works in example app:

https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/04f4219b-6551-46a7-9b10-6b4b15de106c

MarceloPrado commented 1 year ago

Awesome, I agree with your take on capturing the input coordinates instead of the view. And thanks for the demo and explanation, will debug what I'm doing wrong.

MarceloPrado commented 1 year ago

Figured out what happened. I was passing flex: 1 to the scroll view's contentContainerStyle. This caused a lot of issues. Once I removed it, the base code from /examples worked great!

MarceloPrado commented 1 year ago

@kirillzyusko I think I need your input here. I noticed the provided example doesn't work properly if you need the scroll view's content to fill the available space.

Once I add flexGrow: 1 to the contentContainerStyle, and center the content, here's what happens:

https://github.com/kirillzyusko/react-native-keyboard-controller/assets/8047841/e1312349-1e7b-4e77-a153-cc52ddd9d926

While it works smoothly without the flex-grow, if you need to center the content of the scroll view, you have no workaround. Have you seen this before?

Minimal repro:

const styles = StyleSheet.create({
  centered: {
    alignItems: "center",
    flex: 1,
    justifyContent: "center",
  },
  container: {
    flex: 1,
  },
  contentContainer: {
    backgroundColor: "#f7d7d7",
    flexGrow: 1,
  },
});

const Centered: FC<{ children: ReactNode }> = ({ children }) => (
  <View style={styles.centered}>{children}</View>
);

function randomColor() {
  return "#" + Math.random().toString(16).slice(-6);
}

export function AwareScrollView() {
  useResizeMode();

  return (
    <KeyboardAwareScrollView
      contentContainerStyle={styles.contentContainer}
      style={styles.container}
    >
      <Centered>
        {new Array(4).fill(0).map((_, i) => (
          <TextInput
            key={i}
            placeholder={`${i}`}
            placeholderTextColor="black"
            style={{
              width: "100%",
              height: 50,
              backgroundColor: randomColor(),
              marginTop: 50,
            }}
          />
        ))}
      </Centered>
    </KeyboardAwareScrollView>
  );
}
kirillzyusko commented 1 year ago

Hi @MarceloPrado No, I haven't seen this before. I'll try to have a look on your code tomorrow or in nearest days πŸ‘

That's strange - current behaviour looks like a KeyboardAvoidingView πŸ€·β€β™‚οΈ I think it should be fixable anyway, because you have all variables to calculate the trajectory of content movement, but I'll try to have a look when I have free time for that!

MarceloPrado commented 1 year ago

Hi @kirillzyusko, just a friendly ping - had you had any time to investigate this issue? Thanks in advance!

kirillzyusko commented 1 year ago

Hi @MarceloPrado

Not exactly this issue, but I've got some requests of what could be improved in the library when you have to deal with avoiding functionality and I was busy with that - was trying to design a new API/integrate new functionality into existing methods and got some success.

New KeyboardAwareScrollView handles more cases - it has stable bottom-padding (right now it depends on touch area and sometimes keyboard can be very close to the input), it handles TextInput switches when keyboard is open, and I believe can even handle case when multiline TextInput grows😎

Overall the new version of KeyboardAwareScrollView feels like a much better version/revision of what I had before, so my plan is to prepare a new release (1.6.0) and include a new enhanced API, and once it's done - I'll get back to this issue πŸ‘€

My expectation is that new release preparation will take about a month and after that I will switch to resolving all opened issues including this one☺️

MarceloPrado commented 1 year ago

That's awesome @kirillzyusko, I'm happy to hear you're coming up with a more powerful API. This is such an important (and hard) problem in the current React Native ecosystem πŸ™‚

I hope everything works out as you expect in the new API. Let me know if you need any help testing these cases, happy to help.

NguyenHoangMinhkkkk commented 1 year ago

https://github.com/kirillzyusko/react-native-keyboard-controller/assets/30792140/8a42ab97-5641-4b67-b36a-c826776b961c

when i focus on TextInput 5, it is a space between textinput and keyboard. how can i make it exaclty fit ?

kirillzyusko commented 1 year ago

@NguyenHoangMinhkkkk this is because everything depends on touch area (if you are using version below 1.5.8).

In 1.6.0 it'll be possible to measure layout without relying on touch area - just for reference https://github.com/kirillzyusko/react-native-keyboard-controller/commit/95a5376ac5062f1a6379d89d009ad3cc8681eb25

kirillzyusko commented 1 year ago

@MarceloPrado I had a look on this problem. It happens because AwareScrollView adds <Reanimated.View style={view} /> below all children.

Since your content is in center and you add an empty view - your content will be pushed up to stay in the center. To overcome this problem you can use contentInset:

const props = useAnimatedProps(() => ({
    contentInset: {
      bottom: fakeViewHeight.value,
    },
  }));

  return (
    <Reanimated.ScrollView
      ref={scrollViewAnimatedRef}
      {...rest}
      onScroll={onScroll}
      animatedProps={props}
      scrollEventThrottle={16}
    >
      {children}
      {/*<Reanimated.View style={view} />*/}
    </Reanimated.ScrollView>

Such inset don't affect content position, but it works only on iOS (since contentInset is iOS specific property). I've tested and this problem is present on Android too, so I need more time to find a proper solution πŸ‘€

BTW if you have any suggestions how to fix this problem - I'll be glad to hear them 😊

VladyslavMartynov10 commented 1 year ago

@kirillzyusko

New version 1.7.0 with Aware Scroll View & KeyboardAvoidingView is amazing. The only problem that I figure out while testing is IOS behavior.

Aware Scroll View issue:

Attaching detailed demo:

https://github.com/kirillzyusko/react-native-keyboard-controller/assets/115457344/7dd5d2dc-c01e-4c2d-8daf-4e0d9b6812f7

As you said before I believe it takes much more time to investigate all these moments including multiline input handling.

Thanks a lot for your job, you made a huge impact for resolving a painful Keyboard handling for react-native πŸ™‚.

kirillzyusko commented 1 year ago

@VladyslavMartynov10 is it iOS feature to scroll to the input if it's not visible and you are typing something? Or iOS just dispatched onStart/onEnd events from keyboard lifecycle?

Basically, if you need to maintain TextInput visible while typing - you can achieve that just by calling maybeScroll when onTextChanged is fired (of course with some debounce in order not to overuse CPU).

VladyslavMartynov10 commented 1 year ago

I'm not sure if iOS has this functionality out of the box, but it seems that only 'onStart' and 'onEnd' events are dispatched during the keyboard lifecycle. Thanks for the maybeScroll idea, I will investigate.

kirillzyusko commented 1 year ago

@VladyslavMartynov10 I investigated this topic a little bit more and it seems like iOS fires keyboardWillShow/keyboardDidShow events when user types a first symbol in TextInput:

https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/f9e53356-0d90-4e06-80ad-f0a3a2ea70ff

I don't know why it happens in this way, but it seems like Apple engineers needed in this functionality πŸ˜…

So I'd suggest you to go with calling maybeScroll when onTextChanged event is fired (with debounce/throttle). In this case your focused TextInput will always remain above the keyboard even if the user scrolled it to invisible part of the screen.

VladyslavMartynov10 commented 1 year ago

@kirillzyusko Thanks!

Recently I've created the new Swift UI project in order to be sure that the problem lies in IOS implementation itself. This feature is not supported out of box, so we have to create our own logic. I don't know why Apple doesn't include it, sounds funny πŸ˜….

So yeah maybeScroll is the most convenient solution in this case :)

MarceloPrado commented 1 year ago

@kirillzyusko sorry for the delay, if possible, let's try keeping this issue to the original thread to ensure we're all talking about the same thing πŸ™‚

To recap for everyone, the existing KeyboardAwareScrollView adds a "dummy" Reanimated view at the bottom that grows in size relative to the keyboard progress. This is what enables the view to actually scroll once the keyboard is shown. However, there's a downside: if you need a vertically centered scroll view, this implementation causes your view to scroll even in unwanted cases as seen here.

@kirillzyusko I have to spend some time prototyping. One immediate approach I can think of is to add a top "dummy" view to counter the bottom one in this case. I'm not sure how the "syncing" would happen. Not very fond of this since it's easy to get messy.

One other way: when the keyboard is shown, I think it's valid to assume we don't want to "respect" the vertically centered layout. I wonder if we could de-activate flexGrow: 1/flex: 1 once progress > 0 ?

kirillzyusko commented 1 year ago

I have to spend some time prototyping. One immediate approach I can think of is to add a top "dummy" view to counter the bottom one in this case. I'm not sure how the "syncing" would happen. Not very fond of this since it's easy to get messy.

@MarceloPrado Cool idea. I think it will compensate the movement, but in casual scenarios (when you have a lot of TextInputs and they take more space than height of the screen) after keyboard is shown you'll be able to scroll to top and you will see this "fake" view.

One other way: when the keyboard is shown, I think it's valid to assume we don't want to "respect" the vertically centered layout. I wonder if we could de-activate flexGrow: 1/flex: 1 once progress > 0 ?

@MarceloPrado we can de-activate these styles by setting undefined and I think it could be a good option to try πŸ‘ Need to experiment to see whether such approach is not causing additional problems (such as layout jump, etc.).

I've also tried to use react-native-keyboard-aware-scrollview to see whether this package has the same problem. And on iOS this package doesn't have this problem because they are setting contentInset (I haven't tested Android, but may assume, that this OS has the same problem).

Another approach that I was going to check was to use react-native-avoid-softinput and see how such layout is handled there and whether it has the same problem as described here πŸ‘€

VladyslavMartynov10 commented 11 months ago

@kirillzyusko

Recently I was trying to migrate to new version 1.9.4 and lost scroll-to-focused input effect at least on Android. For now when the input is focused and under keyboard, maybeScrollCallback never fires.

I've realised that we've got a discussion before https://github.com/kirillzyusko/react-native-keyboard-controller/issues/168#issuecomment-1712495008 with the support of this feature, but I think it should be handled somehow on native side in order to avoid multiple calls of the same callback in RN.

Any ideas how it can be achieved without reinventing the wheel & performance lost? Thanks beforehand πŸ™‚!

kirillzyusko commented 11 months ago

Hello @VladyslavMartynov10 πŸ‘‹

Would you mind to create a new issue? I remember your problem (to keep focused input in visible area while user is typing) and I have some ideas on how to handle it on a KeyboardAwareScrollView level without adding code on user components level.

So, please, create a new issue and we will discuss an approach with you there (don't want to mix different problems in this single issue).

VladyslavMartynov10 commented 11 months ago

Hello @VladyslavMartynov10 πŸ‘‹

Would you mind to create a new issue? I remember your problem (to keep focused input in visible area while user is typing) and I have some ideas on how to handle it on a KeyboardAwareScrollView level without adding code on user components level.

So, please, create a new issue and we will discuss an approach with you there (don't want to mix different problems in this single issue).

@kirillzyusko Did it

kirillzyusko commented 7 months ago

Another workaround was discovered in #405 - you can specify minHeight so that the content in KeyboardAwareScrollView will not be resized. So code can look like:

const STATUS_BAR_HEIGHT = 44;
const HEADER_HEIGHT = 56;
const styles = StyleSheet.create({
  centered: {
    alignItems: "center",
    flex: 1,
    justifyContent: "center",
    minHeight:
      Dimensions.get("window").height - STATUS_BAR_HEIGHT - HEADER_HEIGHT, // <- fix is here
  },
  container: {
    flex: 1,
  },
  contentContainer: {
    backgroundColor: "#f7d7d7",
    flexGrow: 1,
  },
});

const Centered: FC<{ children: ReactNode }> = ({ children }) => (
  <View style={styles.centered}>{children}</View>
);

function randomColor() {
  return "#" + Math.random().toString(16).slice(-6);
}

export default function AwareScrollView() {
  useResizeMode();

  return (
    <KeyboardAwareScrollView
      contentContainerStyle={styles.contentContainer}
      style={styles.container}
    >
      <Centered>
        {new Array(4).fill(0).map((_, i) => (
          <TextInput
            key={i}
            placeholder={`${i}`}
            placeholderTextColor="black"
            style={{
              width: "100%",
              height: 50,
              backgroundColor: randomColor(),
              marginTop: 50,
            }}
          />
        ))}
      </Centered>
    </KeyboardAwareScrollView>
  );
}