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.38k stars 55 forks source link

snapToOffsets / snapToInterval if keyboard moves scrollview (KeyboardAwareScrollView) #450

Closed kirillzyusko closed 1 month ago

kirillzyusko commented 1 month ago

Discussed in https://github.com/kirillzyusko/react-native-keyboard-controller/discussions/449

Originally posted by **kgni** May 17, 2024 Hi! Not really sure where to put a question like this. If this is the wrong place, please let me know and I will repost my questions somewhere else :) I have the following layout, where I have a `header` and a `stickyHeader`. ### Header: ![CleanShot 2024-05-17 at 21 12 29](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/84397151/d352246b-e082-4d01-9d8f-bcac2be52cdb) ### Sticky Header (scrolled) ![CleanShot 2024-05-17 at 21 12 56](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/84397151/fa3a1712-8c86-4b39-a909-d0d71048ad19) When scrolling the header will disappear and I also have `snapToOffsets` set for when you are scrolling manually (not the keyboard scrolling), to ensure that I don't have any states in between the header being shown and hidden - E.G both the sticky header title and the normal header title would be shown at the same time. What I'm having trouble with, is when I have an input that only causes the keyboard to scroll a tiny bit, instead of snapping to the interval to prevent multiple "states" appearing at the same time: ![CleanShot 2024-05-17 at 21 10 42](https://github.com/kirillzyusko/react-native-keyboard-controller/assets/84397151/bed08178-9002-4622-969f-ae9337cf8267) Essentially what I want to do, is to ensure that the keyboard as a minimum is scrolling the scrollview to the `snapToOffset` / `snapToInterval` position Does anyone have an idea of how I can implement this? Thanks in advance! Here is my code for reference: ```javascript import CONSTANTS from '@constants'; import { usePadding, useScreenSize } from '@hooks'; import { useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { forwardRef, useCallback, useEffect, useState } from 'react'; import { LayoutChangeEvent, LayoutRectangle, ScrollViewProps, View, } from 'react-native'; import { KeyboardAvoidingView, KeyboardAwareScrollView, useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'; import Animated, { AnimatedRef, interpolate, useAnimatedRef, useAnimatedStyle, useScrollViewOffset } from 'react-native-reanimated'; import { PressableIcon } from '@ui'; import { COLORS } from '@app/ui/styles'; import { cn } from '@app/utils'; import { Text } from '../../Text/Text'; import ScrollViewBackgroundLayer from '../ScrollViewBackgroundLayer'; interface SliverLayoutProps extends ScrollViewProps { header?: JSX.Element; stickyHeader?: JSX.Element; setStickyHeaderHeightParent?: (height: number) => void; // this is an optional prop that can be used to get the sticky header height in the parent - bad design? body: JSX.Element; bodyStickyHeader?: JSX.Element; footer?: JSX.Element; stickyFooter?: JSX.Element; withBackButton?: boolean; scrolledTitle?: string; scrolledSubtitle?: string; stickyFooterClassName?: string; onPressBackButton?: () => void; } const SliverLayout = forwardRef( ( { header, stickyHeader, setStickyHeaderHeightParent, // this is an optional prop that can be used to get the sticky header height in the parent - bad design? body, bodyStickyHeader, footer, stickyFooter, stickyFooterClassName, withBackButton = true, scrolledTitle, scrolledSubtitle, onPressBackButton, ...props }: SliverLayoutProps, ref, ) => { let animatedRef = useAnimatedRef(); if (ref) { animatedRef = ref as AnimatedRef; } const scrollOffset = useScrollViewOffset(animatedRef); const { paddingTop, paddingBottom } = usePadding(); const { screenHeight, bottom } = useScreenSize(); const router = useRouter(); const [stickyHeaderHeight, setStickyHeaderHeight] = useState(0); const [headerHeight, setHeaderHeight] = useState(0); const [bodyStickyHeaderHeight, setBodyStickyHeaderHeight] = useState(0); const [stickyFooterHeight, setStickyFooterHeight] = useState(0); const { progress } = useReanimatedKeyboardAnimation(); const [bodyLayout, setBodyLayout] = useState(null); const [stickyFooterLayout, setStickyFooterLayout] = useState(null); const [headerLayout, setHeaderLayout] = useState( null, ); const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { const { height } = event.nativeEvent.layout; setStickyHeaderHeight(height); setStickyHeaderHeightParent?.(height); }, []); const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { const { height } = event.nativeEvent.layout; setHeaderHeight(height); setHeaderLayout(event.nativeEvent.layout); }, []); const handleStickyFooterLayout = useCallback((event: LayoutChangeEvent) => { const { height } = event.nativeEvent.layout; setStickyFooterLayout(event.nativeEvent.layout); setStickyFooterHeight(height); }, []); const headerAnimatedStyle = useAnimatedStyle(() => { return { opacity: interpolate(scrollOffset.value, [0, headerHeight], [1, 0]), transform: [ { translateY: interpolate( scrollOffset?.value ?? 0, [0, 0, headerHeight], [scrollOffset.value, 0, headerHeight], 'clamp', ), }, ], }; }); const fadeInHeader = useAnimatedStyle(() => { return { opacity: interpolate( scrollOffset.value, [0, headerHeight / 2, headerHeight], [0, 0, 1], ), }; }); const footerAnimatedStyle = useAnimatedStyle(() => { return { paddingBottom: interpolate( progress.value, [0, 1], [paddingBottom, bottom], 'clamp', ), }; }); const [minBodyHeight, setMinBodyHeight] = useState(0); useEffect(() => { console.log('running'); setMinBodyHeight( screenHeight - stickyFooterHeight - headerHeight - stickyHeaderHeight, ); if (!bodyLayout?.height) { return; } // If the body height is greater than the minBodyHeight, we need to adjust the minBodyHeight, so that the body can scroll at least to the headerHeight: if (minBodyHeight < bodyLayout?.height) { setMinBodyHeight(screenHeight - stickyFooterHeight); } }, [screenHeight, stickyFooterHeight, headerHeight, bodyLayout?.height]); return ( <> {withBackButton && ( router.back())} /> {scrolledTitle} )} {stickyHeader} {header && ( {header} )} {/* TODO: add refresh indicator */} {bodyStickyHeader && ( { const { height } = event.nativeEvent.layout; setBodyStickyHeaderHeight(height); }} className='rounded-t-3xl bg-background-light px-6 pt-6'> {bodyStickyHeader} )} { setBodyLayout(event.nativeEvent.layout); }} className='px-6 pt-6'> {body} {footer && ( {footer} )} {stickyFooter && ( {stickyFooter} )} ); }, ); export default SliverLayout; ```
kirillzyusko commented 1 month ago

Hello @kgni 👋

Thank you for posting the question and a detailed explanation of what you are trying to achieve.

I've been thinking on how to achieve this and I think there is only one approach.

In https://github.com/kirillzyusko/react-native-keyboard-controller/blob/6da7f58bd692e0eeac26e44fd4960c1f4a31f3c0/src/components/KeyboardAwareScrollView/index.tsx#L157

We derive the distance that we need to scroll to make sure the keyboard is not overlapping focused input.

I think this code needs to be slightly modified - instead of these values (let's say this array produces output [0, 50]) we need to search a similar distance in snapToOffsets and do interpolation based on this. So if snapToOffsets=[0, 100] then we beed to exchange our [0, 50] interval with [0, 100].

The only challenge here is to find this suitable interval, because snapToOffsets size can be > 2 elements.

What do you think about this? Maybe you have other ideas on how to achieve this?

kgni commented 1 month ago

@kirillzyusko sounds great!

To get around the snapToOffsets possibly having more than 1 value, would it be possible to do a comparison to check where the current scroll position/height falls in between the list of snapToOffsets and then just scroll to the closest value that is greater than the current scroll position?

E.G

snapToOffset = [200, 400]

currentScrollPosition = 100 -> scroll to 200 currentScrollPosition = 300 -> scroll to 400

Not sure if this can even be done, or if it is bad for performance - just a thought :)

btw, thanks for a great library and for answering my question so quickly!

kirillzyusko commented 1 month ago

To get around the snapToOffsets possibly having more than 1 value, would it be possible to do a comparison to check where the current scroll position/height falls in between the list of snapToOffsets and then just scroll to the closest value that is greater than the current scroll position?

I think it actually tricker, because if you have snapToOffset = [100, 110] but interpolated scroll should be [100, 150] (from 100 -current position, to 150 where input is not obscured by keyboard). Then according to your algorithm we should scroll to 110 but most likely it will cause a keyboard to overlap a focused input.

Not sure if this can even be done, or if it is bad for performance - just a thought :)

I think it should have 0 impact for performance (because we just going to change interpolation range) 😎

for answering my question so quickly!

Actually this week and part of the next week I'm on a vacation and I'm slow to respond 🙈

You can experiment with the interpolation range algorithm and submit a PR - I'll be happy to review it (actually it's not very difficult to add - just need to understand the workflow of events in KeyboardAwareScrollView component and make some modifications into maybeScroll function) 🙌 Or you can contribute and create a new example page (in example app) - that will also speed up a development for this feature request 😊

By the way - other keyboard avoiding solutions such as react-native-keyboard-aware-scroll-view or react-native-avoid-softinput - do they have the same problem of ignoring snapToOffsets property or you haven't tested them? 👀

kgni commented 1 month ago

I think it actually tricker, because if you have snapToOffset = [100, 110] but interpolated scroll should be [100, 150] (from 100 -current position, to 150 where input is not obscured by keyboard). Then according to your algorithm we should scroll to 110 but most likely it will cause a keyboard to overlap a focused input.

I see, makes sense. Not sure how to account for an edge case like this

You can experiment with the interpolation range algorithm and submit a PR - I'll be happy to review it (actually it's not very difficult to add - just need to understand the workflow of events in KeyboardAwareScrollView component and make some modifications into maybeScroll function) 🙌 Or you can contribute and create a new example page (in example app) - that will also speed up a development for this feature request 😊

I'll probably take a look this or next week to see if I can come up with something!

By the way - other keyboard avoiding solutions such as react-native-keyboard-aware-scroll-view or react-native-avoid-softinput - do they have the same problem of ignoring snapToOffsets property or you haven't tested them? 👀

I haven't tested them to be honest. I recently made the switch to react native from flutter, so haven't tried out much :)

kgni commented 1 month ago

@kirillzyusko do you have discord by any chance? Would it be possible to add you there? I don't want to flood the issues here if I stumble upon something

kgni commented 1 month ago

@kirillzyusko, for now, would it be bad to just implement it for 1 offset?

I mean it is pretty advanced to take multiple offsets into account since you might not want to have the keyboard dictating the scroll in all scenarios. However in my case it is pretty obvious how to and that it should be implemented

To gain fine grained control I guess we would have to think more throughly about possible edge cases and scenarios.

The implementation could be something as simple as the following, where we are just clamping the lower value to ensure scroll:


          const interpolatedScrollTo = interpolate(
            e,
            [initialKeyboardSize.value, keyboardHeight.value],
            [
              0,
              Math.max(
                keyboardHeight.value - (height - point) + bottomOffset,
                rest.snapToOffsets?.[0] || 0,
              ),
            ],
          );

And for more control we could add a boolean prop to enable this behaviour as well

Could also turn the Math.max() into a util function with a more descriptive name like, clampMin or clampLower or something

kirillzyusko commented 1 month ago

do you have discord by any chance? Would it be possible to add you there? I don't want to flood the issues here if I stumble upon something

@kgni sure, my nickname is kiryl.ziusko (let me know if you can not find me and need additional information, such us unique number or a link).

The implementation could be something as simple as the following, where we are just clamping the lower value to ensure scroll

Well, it would be good not only clamp to closest value, but to handle other cases s well 😅 Ideally I would like to see a pure function that accepts two params: a range in two values where we need to scroll, i. e. [20, 50] and and array of snapPoints -> and this function should return a new scroll range. In this case it would be only a single function that would be very easy to cover by unit-tests and cover all corner cases (or at least part of them, for example when we have snapPoints=undefined then we should take original range without modifications).

Actually you can play around with ChatGPT - I think it can generate such function and even can help to write tests for that 😄 At least you can try 🙃

And yeah, sorry for a long delay - was on a vacation and just returned from it, so will respond much faster now 👀

kgni commented 1 month ago

my nickname is kiryl.ziusko (let me know if you can not find me and need additional information, such us unique number or a link).

@kirillzyusko can you send me the # as well?

Well, it would be good not only clamp to closest value, but to handle other cases s well 😅 Ideally I would like to see a pure function that accepts two params: a range in two values where we need to scroll, i. e. [20, 50] and and array of snapPoints -> and this function should return a new scroll range.

makes sense, so the snapPoints in this case is the snapOffsets, right? and then I'm guessing the range is calculated from the current position to the closest snapPoint, or something like that

And yeah, sorry for a long delay - was on a vacation and just returned from it, so will respond much faster now 👀

No worries at all, I thought you were still really responsive haha - didn't pay notice to the respond time

kirillzyusko commented 1 month ago

can you send me the # as well?

@kgni this link should redirect you to my profile (couldn't find my id anymore) 😔

makes sense, so the snapPoints in this case is the snapOffsets, right? and then I'm guessing the range is calculated from the current position to the closest snapPoint, or something like that

right 🙂