Ariel Weingarten

Ariel Weingarten

Dangerous Actions Require Safe Buttons

A user interface must not allow an unintentional triggering of a dangerous action. A dangerous action is one that cannot be undone quickly or ever e.g. deleting a GitHub repo

My app, Infogredient, has a dangerous "clear recipe" action for which I built a press 'n hold safety button (github, npm).

You can also play with the live demo.

Building a Safety Button with React Native

The markup is a TouchableWithoutFeedback wrapper over a style-able View which contains whatever children are passed in along with an absolutely positioned View that provides the visual feedback of the button filling up.

<TouchableWithoutFeedback onPressIn={startAction} onPressOut={stopAction}>
  <View style={[style]}>
    {children}
    <View
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        bottom: 0,
        right: 0,
        width: `${progress}%`,
        backgroundColor: progressColor,
      }}
    ></View>
  </View>
</TouchableWithoutFeedback>

The heavy-lifting comes from the Animated feature of react-native. I use progress, a plain React state hook, to drive visual changes in the component. Using progress keeps a clean separation between Animated constructs and react-native components. Animations drive changes in Animated.Values. fillAnimationValue goes from 0 to 100 which drives the width of the fixed view from 0% to 100%. When a finger presses in on the component, the emptyAnimation is stopped and the fillAnimation is started. When the fillAnimation completes it fires the passed in action onPressAndHold and resets the fillAnimationValue. When a finger leaves the component, the fillAnimation is stopped and the emptyAnimation is started.

const [progress, updateProgress] = useState(0)
const [fillAnimationValue] = useState(new Animated.Value(0)) //
fillAnimationValue.addListener(({ value }) => updateProgress(value))

const fillAnimation = Animated.timing(fillAnimationValue, {
  toValue: 100,
  duration: activationThreshold,
})

const emptyAnimation = Animated.timing(fillAnimationValue, {
  toValue: 0,
  duration: deactivationTime,
})

function startAction() {
  emptyAnimation.stop()
  fillAnimation.start(({ finished }) => {
    if (finished) {
      onPressAndHold()
      console.log('button action fired')
      fillAnimationValue.setValue(0)
    } else {
      emptyAnimation.start()
    }
  })
}

function stopAction() {
  fillAnimation.stop()
  emptyAnimation.start()
}

Here's the full code

Feel free to leave a comment/question/pull request on the repo