2

I'm experiencing an odd issue in one of my components where both props and local state are not showing up in an event handler function.

export default function KeyboardState({layout, children}) {

  // Setup local component state
  const [contentHeight, setContentHeight] = useState(layout.height)
  const [keyboardHeight, setKeyboardHeight] = useState(0)
  const [keyboardVisible, setKeyboardVisible] = useState(false)
  const [keyboardWillShow, setKeyboardWillShow] = useState(false)
  const [keyboardWillHide, setKeyboardWillHide] = useState(false)
  const [keyboardAnimationDuration, setKeyboardAnimationDuration] = useState(INITIAL_ANIMATION_DURATION)

  // set up event listeners
  useEffect(()=> {
      let subscriptions = []
      if (Platform.OS === 'ios') {
          subscriptions = [
              Keyboard.addListener('keyboardWillShow', keyboardWillShowHandler),
              // other listeners
          ]
      }
      return ()=> subscriptions.forEach(subscription => subscription.remove())
  }, [])

  // ODD BEHAVIOR
  console.log(layout) // object: {height: 852, width: 414, x: 0, y: 44}
  console.log(contentHeight) // 550
  const keyboardWillShowHandler = (event) => {
      console.log(layout) // object: {height: 0, width: 0, x: 0, y: 0}
      console.log(contentHeight) // 0
      setKeyboardWillShow(true)
      measureEvent(event)
  }

What could possibly be causing this to occur? Within event handlers, we should have access to both the local component props and state, so I can't think of an explanation.

I have confirmed that this component is not receiving an updated layout prop during this time (by using useEffect and passing the layout as a dependency to watch). To me, what's especially odd is that logging within the event handler provides the correct object structure for layout only with values of 0, while the incoming layout prop is unchanged.

Edit:
It appears the issue is with how I am initializing my custom hook that returns layout. I had been initializing it to an object w/ properties height, width, x and y of 0. The inelegant, device-specific solution would be to initialize it to an object with values matching the dimensions of an iPhone X. But how would I dynamically achieve this? And why is my layout changing back to initial state within KeyboardState within event handlers when the component detects no new incoming prop values?

/*UseComponentSize.js (custom hook) */
const useComponentSize = (initial = initialState) => {
  const [layout, setLayout] = useState(initial)
  const onLayout = useCallback(event => {
    console.log(event)
    const newLayout = {...event.nativeEvent.layout}
    newLayout.y = newLayout.y + (Platform.OS === 'android' ? Constants.statusBarHeight : 0)
    setLayout(newLayout)
  }, [])

  return [layout, onLayout]
}

/*App.js*/
const [layout, onLayout] = useComponentSize()
return (
  <View style={styles.container}>
    <View style={styles.layout} onLayout={onLayout}>
      <KeyboardState layout={layout}>
         ...
      </KeyboardState>
   </View>
  </View>
)

Edit 2:

I fixed the problem by initializing layout in my custom hook to null, while revising my App.js to return on the condition that layout was not null. That is:

/*App.js*/
const [layout, onLayout] = useComponentSize()
return (
  <View style={styles.container}>
    <View style={styles.layout} onLayout={onLayout}>
      {layout && (
        <KeyboardState layout={layout}>
           ...
        </KeyboardState>
       )}
     </View>
    </View>
  )

I still don't know why my KeyboardState component returned the layout prop correctly outside an event handler, while returning the useComponentSize() initial state when calling the layout prop within the event handler.

Rahul Nallappa
  • 181
  • 2
  • 9

2 Answers2

1

My handler functions were referencing a stale value, not the updated state.

The reason for this, per this question, is that my handler belongs to the render where it was defined, so subsequent rerenders don't alter the event listener, so it still reads the old value from its render.

This was solved by adding a ref tracking the changes to the layout state:

export default const useKeyboard = () => {
  const [layout, _setLayout] = useState(null)
  const layoutRef = useRef(layout) // initially null
  const [contentHeight, setContentHeight] = useState(null)

  const setLayout = (value) => {
      layoutRef.current = value // keep ref update
      _setLayout(value) // then call setState
  }

  // onLayout function applied to onLayout prop of view component within App.js
  const onLayout = useCallback(event => {
      const newLayout = {...event.nativeEvent.layout}
      newLayout.y = newLayout.y + (Platform.OS === 'android' ? Constants.statusBarHeight : 0)
      setLayout(newLayout) // calls modified setLayout function
      setContentHeight(newLayout.height)
  }, [])

  useEffect(()=> {
      Keyboard.addListener('keyboardWillShow', keyboardWillShowHandler) 
      // cleanup function not shown for brevity
      }, [])

   const keyboardWillShowHandler = (event) => {
      setKeyboardWillShow(true)
      measureEvent(event)
  }
   const measureEvent = (event) => {
      const { endCoordinates: {height, screenY}, duration = INITIAL_ANIMATION_DURATION} = event
      setContentHeight(screenY - layoutRef.current.y) // access ref value
  }

  return {layout: layoutRef.current, onLayout, contentHeight}
}
Rahul Nallappa
  • 181
  • 2
  • 9
0

I don't think using ref will be the right solution here. We are not updating the event listener when any dependancy changed and only the older function was getting called every time which has old data footprint.

Wrap your listener with useCallback with it's needed deps and then pass the listener function as a dep for adding event listener to the fresh function.

const keyboardWillShowHandler = useCallback((event) => {
      console.log(layout) // object: {height: 0, width: 0, x: 0, y: 0}
      console.log(contentHeight) // 0
      setKeyboardWillShow(true)
      measureEvent(event)
  }, [layout, contentHeight]);

useEffect(()=> {
      let subscriptions = []
      if (Platform.OS === 'ios') {
          subscriptions = [
              Keyboard.addListener('keyboardWillShow', keyboardWillShowHandler),
              // other listeners
          ]
      }
      return ()=> subscriptions.forEach(subscription => subscription.remove())
  }, [keyboardWillShowHandler])
RajaSekhar K
  • 127
  • 2
  • 5