0

I would like to create a scrollable multiline text input in react native. I would also like refocusing the text input, after having dismissed the keyboard, to put the cursor at the tapped location.

If we create a <TextInput multiline /> component, everything works as desired when that text input is focused. We can scroll around the text and tap anywhere to place the cursor at the tapped location.

However, if we fill the text input to a point where there's overflow (the text goes off the screen, overflowing the text input's parent container's y-boundary) and dismiss the keyboard, when we then scroll the multiline text input, it automatically focuses itself, pulling up the keyboard. It interprets the scroll gesture as a .focus() call, too - And we'd like to be able to discretely scroll with a swipe gesture and refocus (bringing up the keyboard) with a tap gesture.

There's a workaround which compensates for that, allowing discrete scrolling and refocusing - though I'm not sure it's what we want given the problem which arises afterwards.

(It's worth noting at this point that if you don't want to follow my step-by-step explanation of how I got to where I am and would just like to jump to a minimal reproduction of the issue in an Expo Snack, you can do that here. I also included the same code, but for Typescript at the very end of this post.)

If we create the following variables:

const [isEditingMessage, setIsEditingMessage] = useState<boolean>(false)
const messageTextInput = createRef<TextInput>()

... And we add the following props the the text input ...

<TextInput
  multiline

  // new things
  scrollEnabled={false}
  ref={messageTextInput}
  pointerEvents={isEditingMessage ? "auto" : "none"}
  onFocus={(): void => setIsEditingMessage(true)}
  onBlur={(): void => setIsEditingMessage(false)}
/>

... And we wrap the text input in a Pressable component ...

<Pressable
  onPress={(): void => {
    messageTextInput.current?.focus()
  }
/>

... And (bear with me) we wrap that Pressable inside a scrollview component:

<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="always">

Now things almost work the way we'd like. The scroll view permits scrolling and forwards taps (not swipes) to its child Pressable component because the scroll view's keyboardShouldPersistTaps is set to "always". The text input itself will not receive taps, swipes or gestures of any kind because its pointerEvents prop is set to "none" when it's not focused. The text input can receive focus because when there's a tap on the Pressable component, then its onPress function uses the text input's ref to manually focus it which then toggle's the text input's pointerEvents prop to "auto" so it that can receive further gestures while it's focused.

So when the text field is focused, we can:

  1. Scroll
  2. Move the cursor to any tapped location

However, when unfocused we can only:

  1. Scroll
  2. Refocus (but not place the cursor at the tapped location) - We'll be scrolled to where the cursor had last been placed.

Here is a JS (not TypeScript) version of a minimal working example in an Expo Snack (You'll need to run it in a device simulator or on a local device; the multiline prop on the text input doesn't look like it's working in the web version.)

You can test what I mean by:

  1. Opening that snack
  2. Running it on an iOS simulator or device (not the web version)
  3. Focusing the text input
  4. Moving the cursor to a new location
  5. Tapping the "Drop Keyboard" button
  6. Scrolling the text to a totally new "frame" of lorem ipsum nonsense
  7. Tapping somewhere new to focus the text field.

You'll be refocused (), but you'll also be autoscrolled back to where the cursor was last active before the text input was blurred ().

I'd like that "refocus" to place the cursor at the location of the "refocus" tap rather than scrolling back to the last selection location.

Here's what I'm trying.

Pressable's onPress prop passes us a GestureResponderEvent which we can use to get the coordinates of the tap. Also, we can set the cursor location (by character index) after focusing the text field, right in that same onPress function with some real hack-jank stuff ( by that I'm referring to using a ref to set native props messageTextInput.current?.setNativeProps(...) which definitely works but feels icky):

<Pressable
  onPress={(event): void => {
    messageTextInput.current?.focus()

    const { locationX: x, locationY: y } = event.nativeEvent
    // I think maybe I can use these coordinates from this event
    // and somehow translate them to a character index. Please
    // dont't suggest doing math with a monospaced font - I like
    // nice fonts.

    console.log(`tap coordinates: (${x}, ${y})`)

    messageTextInput.current?.setNativeProps({
      selection: {
        start: fictionalCoordinatesToCharacterIndexFunction(x, y),
        end: fictionalCoordinatesToCharacterIndexFunction(x, y),
      },
    })
  }}
>

The problem is that I have no idea how to implement that fictionalCoordinatesToCharacterIndexFunction(x, y) function. I also think maybe I've already overcomplicated this for myself and I've gone way too far down the wrong path.

Older versions of react native used to have an onTextLayout prop that I think might've been able to help us - but it's not being called anymore and even if it were, I don't think it'd be able to give us a "just right" solution.

Another possible solution may be forwarding a tap event from the Pressable component's onPress function to the child text input at a specific (x, y) coordinate after focusing it. I don't know how to do that - if it's at all possible.

Maybe I'm pulling on several of the wrong ropes.

Also, to be clear, this is not a keyboard avoiding view question - in case I communicated poorly. The problem is with creating a scrollable multiline text input that can refocus accurately.

If typescript's your thing (plain js version here), here's the whole minimal working example in that format:

import {
  Button,
  Keyboard,
  Pressable,
  SafeAreaView,
  ScrollView,
  TextInput,
  View,
} from "react-native"
import { createRef, useState } from "react"
import { SafeAreaProvider } from "react-native-safe-area-context"

const loremIpsumText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean commodo lectus id nisl posuere, eget rhoncus ipsum cursus. Etiam sed placerat neque, id scelerisque erat. Nullam elementum sem eu congue fringilla. Maecenas consectetur dolor vitae placerat pharetra. In nisl dui, faucibus nec augue nec, porttitor ornare augue. Curabitur bibendum, turpis id commodo aliquam, ex purus mollis mi, et dignissim quam lectus ut ligula. Quisque cursus lectus nibh, sed suscipit sapien pulvinar et. Nunc placerat velit eu viverra molestie. Etiam nibh libero, vestibulum a maximus in, consequat id est. Vestibulum tincidunt augue in turpis bibendum, convallis luctus quam mattis. Sed sit amet neque tristique, accumsan tellus eu, suscipit lectus.

Mauris nec nibh sem. Praesent et faucibus est. Sed porttitor ornare gravida. Morbi vel risus faucibus, commodo justo vitae, fringilla sapien. Duis vel dolor eget nisl porta hendrerit. Praesent non cursus sapien, eu vestibulum nisl. Praesent vel mauris eget augue egestas interdum in aliquet tortor. Fusce dapibus tincidunt nisl, sed pretium turpis elementum non. Aliquam egestas magna quis libero convallis convallis.

Sed vitae gravida ipsum, a blandit augue. Phasellus eget porttitor lacus. Nulla vel consequat urna. Praesent lectus ante, maximus pellentesque urna eget, mattis condimentum magna. Donec aliquet tellus neque, vitae imperdiet dui feugiat nec. Nam tempor purus quis arcu mollis, ut venenatis ligula maximus. Aliquam commodo consectetur aliquam. Nam vel nibh ultricies, blandit leo eu, auctor lorem. Nam vehicula purus non velit hendrerit egestas.`

const App = (): React.ReactElement => {
  const [message, setMessage] = useState<string>(loremIpsumText)
  const [isEditingMessage, setIsEditingMessage] = useState<boolean>(false)
  const messageTextInput = createRef<TextInput>()

  return (
    <SafeAreaProvider>
      <SafeAreaView style={{ flex: 1 }}>
        <ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="always">
          <Pressable
            onPress={(event): void => {
              messageTextInput.current?.focus()

              const { locationX: x, locationY: y } = event.nativeEvent
              // I think maybe I can use these coordinates from this event
              // and somehow translate them to a character index. Please
              // dont't suggest doing math with a monospaced font - I like
              // nice fonts.

              console.log(`tap coordinates: (${x}, ${y})`)
              // messageTextInput.current?.setNativeProps({
              //   selection: {
              //     start: fictionalCoordinatesToCharacterIndexFunction(x, y),
              //     end: fictionalCoordinatesToCharacterIndexFunction(x, y),
              //   },
              // })
            }}
          >
            <TextInput
              value={message}
              onChangeText={setMessage}
              multiline
              placeholder="Message"
              style={{ fontSize: 24 }}
              scrollEnabled={false}
              ref={messageTextInput}
              pointerEvents={isEditingMessage ? "auto" : "none"}
              onFocus={(): void => setIsEditingMessage(true)}
              onBlur={(): void => setIsEditingMessage(false)}
            />
          </Pressable>
        </ScrollView>
        <View style={{ flex: 1 }}>
          <Button title="Drop Keyboard" onPress={Keyboard.dismiss} />
        </View>
      </SafeAreaView>
    </SafeAreaProvider>
  )
}

export default App
pachun
  • 926
  • 2
  • 10
  • 26

0 Answers0