Building an Event Creation Interaction— II

Karthik Balasubramanian
Timeless
8 min readJun 24, 2023

--

Hey Everyone, this is the continuation of my previous blog on where we will deep dive into understanding and breaking the Gesture.

Blog Cover

Understanding the Pan Gesture

We would have built the Time Segments, and wrapped them inside the Gesture Detector, you would have seen this in my previous blog.

The final gesture handling code is something like this:

  const panGesture = Gesture.Pan()
.maxPointers(1)
.shouldCancelWhenOutside(true)
.activateAfterLongPress(300)
.onBegin(() => {
const getRandomColor = (): ColorComboType => {
"worklet";
const colors = Object.values(COLORS_COMBO);
const randomIndex = Math.floor(Math.random() * colors.length);
return colors[randomIndex];
};
const randomColor = getRandomColor();
movingSegmentBackgroundColor.value = randomColor.bg;
})
.onStart(event => {
if (event.y <= 0 || event.y >= SEGMENT_HEIGHT * 24) {
return;
}
runOnJS(setGestureState)(event.state);
panActive.value = withSpring(1);
const panStartPos = event.y;
const nearestTimePos =
Math.floor(panStartPos / MINS_MULTIPLIER) * MINS_MULTIPLIER;

startPoint.value = nearestTimePos;
currentPoint.value = startPoint.value;
startIndex.value = startPoint.value;
endIndex.value = startPoint.value + INIT_POINTER_HEIGHT;
hapticSelection && runOnJS(hapticSelection)();
})
.onChange(event => {
/*
** Checking if the event.y is less than or equal to 0 or greater than or equal to (24 * SEGMENT_HEIGHT).
** If it is, it will not perform any gesture related changes.
*/
if (event.y <= 0 || event.y >= SEGMENT_HEIGHT * 24) {
return;
}
runOnJS(setGestureState)(event.state);

if (event.translationY > 0) {
// Gesture has started, and translation is happening downwards
// Write a case on translating the PanGesture into height change snaps
const computedHeight =
Math.ceil(event.translationY / MINS_MULTIPLIER) * MINS_MULTIPLIER;
selectionHeight.value = computedHeight;

// Checking if there is a new end point based on the new computed height,
// if yes setting the new end point
if (endIndex.value !== startIndex.value + computedHeight) {
endIndex.value = startIndex.value + computedHeight;
}
} else if (event.translationY === 0) {
// When there hasnt been any translation the height remains the same
selectionHeight.value = INIT_POINTER_HEIGHT;
} else {
// Write a case on translating the PanGesture into height change snaps on reverse direction
// And also setting a negative margin gives the feel the container is been increasing height on top

const computedHeight =
Math.ceil(Math.abs(event.translationY) / MINS_MULTIPLIER) *
MINS_MULTIPLIER;
selectionHeight.value = computedHeight;

// Checking if there is a new start point based on the new computed height,
// while the finger moves up, if yes setting the new start point
if (endIndex.value - computedHeight !== startIndex.value) {
// while moving in reverse changing the start point
startIndex.value = endIndex.value - computedHeight;
}
marginTop.value = INIT_POINTER_HEIGHT - selectionHeight.value;
}
})
.onEnd(event => {
runOnJS(setGestureState)(event.state);
const getObjectByColor = (color: string): ColorComboType | undefined => {
const colorKeys = Object.keys(COLORS_COMBO) as COLORS[];
const foundColor = colorKeys.find(
key => COLORS_COMBO[key].bg === color,
);
if (foundColor) {
return COLORS_COMBO[foundColor];
}
return undefined;
};
if (currentEvent === null) {
runOnJS(setCurrentEvent)({
id: new Date().getTime(),
color:
getObjectByColor(movingSegmentBackgroundColor.value) ||
COLORS_COMBO.blue,
date: selectedDate,
startTime,
endTime,
totalTime,
title: "",
translateY: 0,
height: 0,
location: "",
});
} else {
runOnJS(setCurrentEvent)({
...currentEvent,
startTime,
endTime,
});
}
movingSegmentBackgroundColor.value = COLORS_COMBO.blue.bg;
hapticSelection && runOnJS(hapticSelection)();
panActive.value = 0;
selectionHeight.value = INIT_POINTER_HEIGHT;
startPoint.value = 0;
currentPoint.value = 0;
endIndex.value = 0;
startIndex.value = 0;
marginTop.value = 0;
});

Yes, I understand this is huge. Worry not. I will break them down and explain what exactly is happening in the code block by block.

We create a Pan Gesture Handler using the Gesture from the Gesture Handler library, Gesture.Pan(), set the maxPointers to 1 because the number of fingers placed on screen to activate the gesture is 1.

We have a Gesture Bounding Box, if the finger moves out of it, we need to cancel the gesture, so we set shouldCancelWhenOutside to true. Furthermore, we would like to activate the gesture after a certain duration so that we don't mess the gesture activation with a hold and scroll interaction.

All thanks to the gesture handler library, we have a activateAfterLongPress function which takes in the duration in ms of the LongPress gesture before Pan(), the gesture is activated.

onBegin() Callback

Next is the onBegin(), which gets triggered when the gesture begins. In this callback, we try to generate a random color for the Active Event and set it to a SharedValue variable. This randomly generated color is set as the background color for the moving segment, using useAnimatedStyle(). This way, every time the gesture is activated, we get a random color for new events.

Random Colors for New Events

onStart() Callback

In both onStart() and onChange() we first check if the gesture is within bounds.

We also set shouldCancelWhenOutside and just to make sure while the gesture is happening we don't extend our event outside the section we do this check.

SS of Time Segment Renderer with the cumulative height

Here you can see the total height of container starting from 0 to 1728.

The value 1728 comes from SEGMENT_HEIGHT * 24, each segment multiplied with the no. of hours in a day.

We have a set of variables for Pan Handling.

const [state, setGestureState] = useState<State>(State.UNDETERMINED);
const panActive = useSharedValue(0);
const startPoint = useSharedValue(0);
const startIndex = useSharedValue(0);
const endIndex = useSharedValue(0);
const currentPoint = useSharedValue(0);

Once the gesture is within bounds, we set the appropriate state to “Gesture State”.

The startIndex and endIndex values are set and used to calculate the time to show it inside the event.

Set the panActive ShareValue variable to 1, using withSpring(), this will be used in styling the container.

Variables and their corresponding indications

The y value from the event is the panStartPos. We use this to find the nearest position, which is a multiple of our MINS_MULTIPLIER.

Using this, we set our variables. The initial endIndex is the sum of startPoint and the Height of the single 30-min segment, which here is 72.

And we give a nice haptic feedback saying you have activated the gesture and created an initial Event which is of a set minute.

onChange() Callback

The onChange() callback can be split into three sections based on how the finger is moved.

Downward Gesture

First, when the finger is moved downward, indicating we need to increase the height of the segment based on a multiplier, which here is 30-min, making it a 60-min event.

So, the first “if” case will handle this, event.translationY > 0.

We need a math formula to map the translation into a multiplier of our mins value.

const computedHeight =
Math.ceil(event.translationY / MINS_MULTIPLIER) * MINS_MULTIPLIER;
selectionHeight.value = computedHeight;

The selectionHeight is a SharedValue variable set to an initial height of the INIT_POINTER_HEIGHT.

When we are moving downwards and the height changes, the endIndex value should be changed because ideally the user is changing the end time of the event he wants to create.

We see if the new end value which is the sum of the startIndex and the newly computed height, if it is different, we set that to the endIndex value.

if (endIndex.value !== startIndex.value + computedHeight) {
endIndex.value = startIndex.value + computedHeight;
}

See it in action here:

Action of moving downwards, changing the height and `endIndex` value.

Translation at Zero

This is the simplest, where the translation is nearing zero, and we need to reset the height to the default initial height.

Upward Gesture

Similar to the “Downward Gesture” we calculate the height based on the translation.

const computedHeight =
Math.ceil(Math.abs(event.translationY) / MINS_MULTIPLIER) *
MINS_MULTIPLIER;
selectionHeight.value = computedHeight;

Apply Math.abs because now the translation is happening in the negative of y-axis.

In this case, the user is trying to change the start time by moving the finger upwards, so we need to change the startIndex value. We do that when the difference of endIndex value and computed height is not equal to startIndex.

if (endIndex.value - computedHeight !== startIndex.value) {
startIndex.value = endIndex.value - computedHeight;
}

See it in action here:

Action of moving upwards, changing the height and `startIndex` value.

You will be wondering how did the container height change follow the finger. We get this feel by a small style hack which involves setting a negative margin height to the container, which is the difference of the current height and the default height of the Segment.

marginTop.value = INIT_POINTER_HEIGHT - selectionHeight.value;

Well, that is it for the whole onChange() Callback.

onEnd() Callback

The onEnd() callback is all about setting the start and end time based on the gesture, opening a Bottom Sheet with a partially filled Event data and resetting all gesture related Shared Value variables.

Using the startIndex and endIndex values, we calculate three values and store it in a state.

const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
const [totalTime, setTotalTime] = useState("");

We need to do some calculations and set those values, we will be converting pixels to mins now. We listen to the changes in the SharedValue variables using the useAnimatedReaction().

  // Pan handling State
useAnimatedReaction(
() => [startIndex.value, endIndex.value],
(next, _prev) => {
const convertedValues = next.map(value => convertPixelsToMinutes(value));
textScale.value = withSpring(
Math.abs(
convertPixelsToMinutes(convertedValues[0]) -
convertPixelsToMinutes(convertedValues[1]),
),
{
mass: 1,
damping: 20,
stiffness: 180,
},
);
const getTimeFromMins = (minutes: number) => {
const hours = Math.floor(minutes / 60) % 24;
const remainingMinutes = minutes % 60;
const time =
(hours < 10 ? "0" + hours : hours) +
":" +
(remainingMinutes < 10 ? "0" + remainingMinutes : remainingMinutes);

return time;
};
runOnJS(setTotalTime)(`${convertedValues[1] - convertedValues[0]}`);
runOnJS(setStartTime)(formatTime(getTimeFromMins(convertedValues[0])));
runOnJS(setEndTime)(formatTime(getTimeFromMins(convertedValues[1])));
},
);

These are essential to calculate because we will be rendering this inside the Event Container.

Well, this wraps the entire Gesture Breakdown. I hope it was insightful.

In the next blog which we will wrap the styling, add the Bottom Sheet component, finish the entire Event Creation Interaction.

Thank you.

This is Karthik from Timeless

See you guys in the final part of my blog.

--

--