npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@osamaq/drag-select

v0.1.2

Published

A utility for creating a pan gesture that auto-selects items in a list, like your favorite gallery app.

Downloads

206

Readme

👆 Drag Select for React Native

A utility for creating a pan gesture that auto-selects items in a list, like your favorite gallery app.

  • Supports Android & iOS
  • Handles scrolling
  • Super performant
  • Headless API: Bring your own UI
  • Works with typical scrollable views - ScrollView, FlatList, FlashList etc.

[!IMPORTANT] This package is in public alpha.

Feedback needed If something is not working as it's supposed to, let me know by submitting an issue. I'm finalizing the initial API and on the lookout for edge cases before releasing v1.0.0

Table of Contents

Usage

Quickstart

This package is made with Reanimated & Gesture Handler, and using it requires some familiarity.

useDragSelect is a utility hook. It works by taking in parameters describing the UI of your list and returns managed gestures.

Paste this snippet into your project to get started.

import { useDragSelect } from "@osamaq/drag-select"

import { FlatList, View, Text } from "react-native"
import { GestureDetector } from "react-native-gesture-handler"
import Animated, { useAnimatedRef, useAnimatedScrollHandler } from "react-native-reanimated"

function List() {
  const data = Array.from({ length: 50 }).map((_, index) => ({
    id: `usr_${index}`,
    name: "foo",
  }))

  const flatlist = useAnimatedRef<FlatList<(typeof data)[number]>>()

  const { gestures, onScroll } = useDragSelect({
    data,
    key: "id",
    list: {
      animatedRef: flatlist,
      numColumns: 2,
      itemSize: { height: 50, width: 50 },
    },
    onItemSelected: (id, index) => {
      console.log("onItemSelected", { id, index })
    },
    onItemDeselected: (id, index) => {
      console.log("onItemDeselected", { id, index })
    },
    onItemPress: (id, index) => {
      console.log("onItemPress", { id, index })
    },
  })

  const scrollHandler = useAnimatedScrollHandler({ onScroll })

  return (
    <GestureDetector gesture={gestures.panHandler}>
      <Animated.FlatList
        data={data}
        ref={flatlist}
        numColumns={2}
        onScroll={scrollHandler}
        renderItem={({ item, index }) => (
          <GestureDetector gesture={gestures.createItemPressHandler(item.id, index)}>
            <View style={{ width: 50, height: 50, backgroundColor: "salmon" }}>
              <Text>{item.id}</Text>
            </View>
          </GestureDetector>
        )}
      />
    </GestureDetector>
  )
}

Step-by-step

1. Create a list

  • The list component must be animated
  • To use autoscrolling, the component must support scrollTo
import Animated from "react-native-reanimated"
import { Text, View } from "react-native"

export function List() {
  const data = Array.from({ length: 50 }).map((_, index) => ({
    id: `usr_${index}`,
    name: "foo",
  }))

  return (
    <Animated.ScrollView>
      {data.map((item) => (
        <View key={item.id}>
          <Text>{item.id}</Text>
        </View>
      ))}
    </Animated.ScrollView>
  )
}

2. Style it

When styling your list, parameters like item size and column gap must be specified in plain numbers.

  • Avoid relative parameters for item size e.g. width: "25%"
  • When positioning items, avoid properties like justifyContent. Instead, specify column/row gap and padding

That is because we will be passing those parameters to useDragSelect and actual item size or location is never measured internally.

const { width: windowWidth } = Dimensions.get("window")

const numColumns = 2
const rowGap = 50
const columnGap = 50

const paddingHorizontal = 24
const listWidth = windowWidth - paddingHorizontal * 2

const itemWidth = (listWidth - columnGap * (numColumns - 1)) / numColumns
const itemHeight = 50

The list layout on the other hand is measured once per pan gesture, which is why we have to pass an animated ref to the hook.

// ...
  const scrollView = useAnimatedRef<Animated.ScrollView>()

  useDragSelect({
    data,
    key: "id",
    list: {
      animatedRef: scrollView,
      columnGap,
      rowGap,
      itemSize: { height: itemHeight, width: itemWidth },
      contentInset: {
        right: paddingHorizontal,
        left: paddingHorizontal,
      },
    },
  })

  return (
    <Animated.ScrollView
      ref={scrollView}
      style={{
        rowGap,
        columnGap,
        paddingHorizontal,
        flexGrow: 1,
        flexDirection: "row",
        flexWrap: "wrap",
      }}
    >
      {data.map((item) => (
        <View
          key={item.id}
          style={{
            width: itemWidth,
            height: itemHeight,
          }}
        >
          <Text>{item.id}</Text>
        </View>
      ))}
    </Animated.ScrollView>
  )

3. Register events

While useDragSelect manages gestures for us, we still have to register them. Create GestureDetector's wrapping the list and each item and a scroll event handler.

  • Long pressing an item activates 'selection mode'
  • A long press followed by a pan gesture selects items
  • In 'selection mode', tapping an item selects it
  const { gestures, onScroll } = useDragSelect(...)

  const scrollHandler = useAnimatedScrollHandler({ onScroll })

  return (
    <GestureDetector gesture={gestures.panHandler}>
      <Animated.ScrollView
        onScroll={scrollHandler}
        ref={...}
        style={...}
      >
        {data.map((item, index) => (
          <GestureDetector
            key={item.id}
            gesture={gestures.createItemPressHandler(item.id, index)}
          >
            <View style={...}>
              <Text>{item.id}</Text>
            </View>
          </GestureDetector>
        ))}
      </Animated.ScrollView>
    </GestureDetector>
  )

4. Build the rest 🦉

The rest is up to you!

[!TIP]

  • Use methods on the selection object to imperatively add, delete or clear items from the JS thread
  • Use selection.items to drive animations with Reanimated

Check out recipes for more fleshed out examples.

Installation

[!IMPORTANT] This package requires Reanimated v3 and Gesture Handler v2.

npm install @osamaq/drag-select
yarn add @osamaq/drag-select
pnpm add @osamaq/drag-select

API

import { useDragSelect } from "@osamaq/drag-select"

useDragSelect(config: Config): DragSelect

Config

interface Config<ListItem = unknown> {
  /**
   * The same array of items rendered on screen in a scrollable view.
   */
  data: Array<ListItem>
  /**
   * Key or path to nested key which uniquely identifies an item in the list.
   * Nested key path is specified using dot notation in a string e.g. `"user.id"`.
   *
   * @example
   * const item = { id: "usr_123", name: "foo" }
   * useDragSelect({ key: "id" })
   */
  key: PropertyPaths<ListItem>
  list: {
    /**
     * An [animated ref](https://docs.swmansion.com/react-native-reanimated/docs/core/useAnimatedRef) to
     * the scrollable view where the items are rendered.
     *
     * @example
     * const animatedRef = useAnimatedRef()
     * useDragSelect({ list: { animatedRef } })
     * return <Animated.FlatList ref={animatedRef} />
     */
    animatedRef: AnimatedRef<any>
    /**
     * Number of columns in the list.
     * @default 1
     */
    numColumns?: number
    /**
     * Amount of horizontal space between rows.
     * @default 0
     */
    rowGap?: number
    /**
     * Amount of vertical space between columns.
     * @default 0
     */
    columnGap?: number
    /**
     * Height and width of each item in the list.
     */
    itemSize: {
      width: number
      height: number
    }
    /**
     * Inner distance between edges of the list container and list items.
     * Use this to account for list headers/footers and/or padding.
     */
    contentInset?: {
      top?: number
      bottom?: number
      left?: number
      right?: number
    }
  }
  /**
   * Configuration for the long press gesture. Long pressing an item activates selection mode.
   * When selection mode is active, tapping any item will add or remove it from selection.
   */
  longPressGesture?: {
    /**
     * Whether long pressing to activate selection mode is enabled.
     * @default true
     */
    enabled?: boolean
    /**
     * The amount of time in milliseconds an item must be pressed before selection mode activates.
     * @default 300
     */
    minDurationMs?: number
  }
  /**
   * Configuration for automatic scrolling while panning gesture.
   */
  panScrollGesture?: {
    /**
     * Whether pan-scrolling is enabled.
     * @default true
     */
    enabled?: boolean
    /**
     * How close should the pointer be to the start of the list before **inverse** scrolling begins.
     * A value between 0 and 1 where 1 is equal to the height of the list.
     * @default 0.15
     */
    startThreshold?: number
    /**
     * How close should the pointer be to the end of the list before scrolling begins.
     * A value between 0 and 1 where 1 is equal to the height of the list.
     * @default 0.85
     */
    endThreshold?: number
    /**
     * The maximum scrolling speed when the pointer is near the starting edge of the list window.
     * Must be higher than 0.
     * @default
     *  - 8 on iOS
     *  - 1 on Android
     */
    startMaxVelocity?: number
    /**
     * The maximum scrolling speed when the pointer is at the ending edge of the list window.
     * Must be higher than 0.
     * @default
     *  - 8 on iOS
     *  - 1 on Android
     */
    endMaxVelocity?: number
  }
  /**
   * Invoked on the JS thread whenever an item is tapped, but not added to selection.
   *
   * You may still wrap items with your own pressable while still using this callback to handle presses. This should be more convenient than managing button `disabled` state based on whether selection mode manually.
   */
  onItemPress?: (id: string, index: number) => void
  /**
   * Invoked on the JS thread whenever an item is added to selection.
   */
  onItemSelected?: (id: string, index: number) => void
  /**
   * Invoked on the JS thread whenever an item is removed from selection.
   */
  onItemDeselected?: (id: string, index: number) => void
}

DragSelect

interface DragSelect {
  /**
   * Must be used with [`useAnimatedScrollHandler`](https://docs.swmansion.com/react-native-reanimated/docs/scroll/useAnimatedScrollHandler)
   * and passed to the animated list to use the pan-scroll gesture.
   * Used to obtain scroll offset and list window size.
   *
   * @example
   * const { onScroll } = useDragSelect()
   * const scrollHandler = useAnimatedScrollHandler(onScroll)
   * return <Animated.FlatList onScroll={scrollHandler} />
   */
  onScroll: (event: ReanimatedScrollEvent) => void
  gestures: {
    /**
     * This returns a composed [tap](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture) and
     * [long-press](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/long-press-gesture) gesture.
     * Note that the long press gesture can be disabled by setting `config.longPressGesture.enabled` to `false`. See {@link Config.longPressGesture}.
     *
     * Do not customize the behavior of this gesture directly.
     * Instead, [compose](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/composed-gestures) it with your own.
     *
     */
    createItemPressHandler: (id: string, index: number) => SimultaneousGesture
    /**
     * This is a single [pan gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture).
     * If you need to rely solely on pressing items for selection, you can disable the pan gesture by setting `config.panScrollGesture.enabled` to `false`. See {@link Config.panScrollGesture}.
     *
     * Do not customize the behavior of this gesture directly.
     * Instead, [compose](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/composed-gestures) it with your own.
     */
    panHandler: PanGesture
  }
  selection: {
    /**
     * Whether the selection mode is active.
     *
     * When active, tapping list items will add them or remove them from selection.
     * Config callbacks {@link Config.onItemSelected} and {@link Config.onItemDeselected} will be invoked instead of {@link Config.onItemPress}.
     */
    active: DerivedValue<boolean>
    /**
     * Add an item to selection. When there are no selected items, adding a single item to selection activates selection mode.
     *
     * Must be invoked on the JS thread.
     * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
     */
    add: (id: string) => void
    /**
     * Clear all selected items. Clearing selected items automatically deactivates selection mode.
     * Note that this does not trigger {@link Config.onItemDeselected}.
     *
     * Must be invoked on the JS thread.
     * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
     */
    clear: () => void
    /**
     * Remove an item from selection.
     * When the last item is removed from selection, selection mode is deactivated.
     *
     * Must be invoked on the JS thread.
     * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
     */
    delete: (id: string) => void
    /**
     * Indicates whether an item is selected.
     *
     * Must be invoked on the JS thread.
     * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
     */
    has: (id: string) => boolean
    /**
     * Count of currently selected items.
     */
    size: DerivedValue<number>
    /**
     * A mapping between selected item IDs and their indices.
     */
    items: DerivedValue<Record<string, number>>
    /**
     * Counterpart API for the UI thread.
     */
    ui: {
      /**
       * Add an item to selection. When there are no selected items, adding a single item to selection activates selection mode.
       *
       * Must be invoked on the UI thread.
       * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
       */
      add: (id: string) => void
      /**
       * Clear all selected items. Clearing selected items automatically deactivates selection mode.
       * Note that this does not trigger {@link Config.onItemDeselected}.
       *
       * Must be invoked on the UI thread.
       * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
       */
      clear: () => void
      /**
       * Remove an item from selection.
       * When the last item is removed from selection, selection mode is deactivated.
       *
       * Must be invoked on the UI thread.
       * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
       */
      delete: (id: string) => void
      /**
       * Indicates whether an item is selected.
       *
       * Must be invoked on the UI thread.
       * Note that updates are reflected asynchronously on the JS thread and synchronously on the UI thread.
       */
      has: (id: string) => boolean
    }
  }
}

Recipes

The recipes app contains sample integrations of drag-select.

https://github.com/user-attachments/assets/0e5697d2-aaf8-4888-bfc6-8186f5b0d04b

Remarks: FlatList, haptic feedback on selection change.

https://github.com/user-attachments/assets/1087672e-49e8-4463-813a-0d9c7b162921

Remarks: ScrollView, items are animated Pressable components.

Performance

Running this utility is not inherently expensive. It works by doing some math on every frame update and only when panning the list. In my testing, I could not manage to get any frame drops at this point. However...

Performance cost comes from the additional logic added in response to changes in selection. You can easily cause frame drops by running expensive animations.

[!TIP] Try to be conservative in list item animations on selection change.

  • Certain components and properties are more costly to animate than others
  • Don't animate too many things at once

Running on iPhone 12 mini in dev mode.

Currently Not Supported

  • Horizontal lists
  • Inverted lists
  • Lists with dynamic item size
  • Scroll view zoom
  • Section lists

Known Issues

  • Android, new architecture: In the new architecture, automatic scrolling will lead to the app hanging with an ANR notification. This appears to be a bug with React Native which is fixed in 0.77+

Development

This project uses pnpm.

pnpm install
pnpm dev start

Acknowledgements

Consider supporting the following projects: