Building an Event Creation Interaction — III

Karthik Balasubramanian
Timeless
9 min readJun 27, 2023

--

Hey Everyone, this is the final blog in the series, where we will wrap the Event Creation, will discuss the styling and use a Bottom Sheet to get Event related details.

Checkout previous blogs:

Blog Cover

Animating Event on Gesture

You would have already seen that we would have created the <TimeSegmentRender /> component, wrapped it insider a Gesture Detector, and it is wrapped inside a Animated.ScrollView, we will now place our “Event Container” inside the ScrollView.

<Animated.View
style={[
StyleSheet.absoluteFill,
tailwind.style("left-15 overflow-hidden"),
movingSegmentStyle,
]}
>
<Animated.View layout={Layout.springify()}>
<Animated.Text
style={[
tailwind.style("font-medium text-white"),
textContainerStyle,
]}
>
{startTime}-{endTime}
{"\n"}
{"New Event"}
</Animated.Text>
</Animated.View>
</Animated.View>

The startTime and endTime are state variables set using the startIndex and endIndex as you have seen in my previous blog.

Let us look at the animating styles, movingSegmentStyle and textContainerStyle.

movingSegmentStyle

const movingSegmentStyle = useAnimatedStyle(() => {
return {
backgroundColor: movingSegmentBackgroundColor.value,
top: startPoint.value + 2,
height: withSpring(selectionHeight.value - 4, {
mass: 1,
damping: 30,
stiffness: 250,
}),
opacity: interpolate(
panActive.value,
[0, 1],
[0, 1],
Extrapolation.CLAMP,
),
borderRadius: interpolate(
selectionHeight.value,
[0, 30, 45],
[0, 10, 12],
Extrapolation.CLAMP,
),
width: interpolate(
panActive.value,
[0, 1],
[0, SEGMENT_WIDTH],
Extrapolation.CLAMP,
),
marginTop: withSpring(marginTop.value, {
mass: 1,
damping: 30,
stiffness: 250,
}),
zIndex: 99999,
};
}, []);

At first, we set the backgroundColor of the component, set from the randomly generated color and set to the SharedValue variable, movingSegmentBackgroundColor.

The whole segment is absolutely positioned, so we set the top to the startPoint animated value, with an additional offset of 2 because we need a small spacing on top of the container. We also set the height to selectionHeight with a reduced value of 4, so the 60-min segment is in between the dividers, using withSpring animation to get a smooth bouncy animation.

The Spacing on top and bottom is the significance of offset and height change

Next, the opacity of the component is set using the panActive value, which interpolates from 0 to 1 and opacity changes from 0 to 1.

Another property dependent on the panActive value is the width, it interpolates between 0 to the SEGMENT_WIDTH.

These two changes happen when the gesture has begun.

Now for border radius, it changes with the Segment Height, which keeps on changing while the gesture is happening. So, we interpolate it using the selectionHeight SharedValue variable, between 0 and 30 the border radius will be from 0px to 10px and from 30 to 45 it changes from 10 to 12px, meaning a 30-min Event will have a border radius of 10px and anything above that will have a border radius 12px. The Extrapolation.CLAMP parameter ensures that the output value doesn't exceed the specified range.

The marginTop of the component is simple, we have already calculated the value, and you know why it is needed. We just set it to the value using the withSpring().

We want this component to appear on top of all other, so set a high value to the zIndex.

textContainerStyle

const textContainerStyle = useAnimatedStyle(() => {
return {
fontSize: interpolate(
textScale.value,
[30, 60],
[10, 12],
Extrapolation.CLAMP,
),
transform: [
{
translateY: interpolate(
textScale.value,
[30, 60],
[4, 8],
Extrapolation.CLAMP,
),
},
{
translateX: interpolate(
textScale.value,
[30, 60],
[6, 8],
Extrapolation.CLAMP,
),
},
],
};
});

The SharedValue variable textScale is set to a value which is the difference of the startTime and endTime, which is the total time of the Event.

As we have set the minimum Event time as 30-min, the font size would be 10px for a 30-min event and 12px for a 60-min event. The same way, we also translate the text to position it properly based on a 30-min Event and a 60-min event.

Change of Font Size and Transform styles

Event Data Handling using Bottom Sheet

I have used Zustand to manage data for the Events. This is the Zustand store. I have some predefined colors, which would have used to create Random colors every time a gesture begins.

import { create } from "zustand";

export interface ColorComboType {
bg: string;
text: string;
}

export type COLORS =
| "red"
| "purple"
| "pink"
| "blue"
| "green"
| "black"
| "plum"
| "orange";

const COLORS_COMBO: Record<COLORS, ColorComboType> = {
purple: { bg: "#8B32FC", text: "#ffffff" },
plum: { bg: "#00ACEB", text: "#ffffff" },
blue: { bg: "#315EFD", text: "#ffffff" },
pink: { bg: "#FF5391", text: "#ffffff" },
green: { bg: "#30a46c", text: "#ffffff" },
black: { bg: "#000000", text: "#ffffff" },
red: { bg: "#e5484d", text: "#ffffff" },
orange: { bg: "#FF5C28", text: "#ffffff" },
};

export interface CalendarEvent {
id: number;
title: string;
date: string;
startTime: string;
endTime: string;
color: ColorComboType;
translateY: number;
totalTime: string;
height: number;
location: string;
}

interface CalendarEventsStore {
events: CalendarEvent[];
addEvent: (event: CalendarEvent) => void;
removeEvent: (id: number) => void;
}

const useEventStore = create<CalendarEventsStore>(set => ({
events: [],
addEvent: (event: CalendarEvent) =>
set((state: CalendarEventsStore) => ({
events: [...state.events, event],
})),
removeEvent: (id: number) =>
set((state: CalendarEventsStore) => ({
events: state.events.filter(event => event.id !== id),
})),
}));

export { COLORS_COMBO, useEventStore };

The addEvent() is used to add and update Events data into Zustand store.

We would have used a simple local state (useState) to store the partial data in the onEnd() callback of the gesture, which would be essential in filling up the initial Bottom Sheet, then we use the addEvent() when we have all the details.

We have certain Bottom Sheet related props and events.

  // Bottomsheet related props
// hooks
const sheetRef = useRef<BottomSheet>(null);

// variables
const snapPoints = useMemo(() => ["40%"], []);

const handleOnChangeText = useCallback(
(text: string) => {
if (currentEvent) {
setCurrentEvent({ ...currentEvent, title: text });
}
},
[currentEvent],
);

const handleOnChangeLocation = useCallback(
(text: string) => {
if (currentEvent) {
setCurrentEvent({ ...currentEvent, location: text });
}
},
[currentEvent],
);

const handleAddEventPress = useCallback(() => {
if (currentEvent && currentEvent.title.length >= 5) {
const sectionMeasurement = getSectionMeasurements(
currentEvent.startTime,
currentEvent.endTime,
);
eventStore.addEvent({
...currentEvent,
translateY: sectionMeasurement.translateYValue,
height: sectionMeasurement.heightValue,
});
setCurrentEvent(null);
sheetRef.current?.close();
inputRef.current?.blur();
locationInputRef.current?.blur();
} else {
Alert.alert(
"Enter event title",
"Event title must be longer than 4 characters.",
[{ text: "OK", onPress: () => inputRef.current?.focus() }],
);
}
}, [currentEvent, eventStore]);

const handleOnCloseSheet = useCallback(() => {
sheetRef.current?.close();
inputRef.current?.blur();
locationInputRef.current?.blur();
setCurrentEvent(null);
}, []);

// renders
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
pressBehavior={"none"}
{...props}
opacity={0.75}
appearsOnIndex={0}
disappearsOnIndex={-1}
/>
),
[],
);

These functions are used to get the Event Title (handleOnChangeText), we have a function handleAddEventPress, which is called when the keyboard done button is clicked. Which resets the local event state and adds that event to the Zustand Store.

The Bottom Sheet render is something like this:

<BottomSheet
index={-1}
enablePanDownToClose
backdropComponent={renderBackdrop}
ref={sheetRef}
snapPoints={snapPoints}
handleStyle={styles.handleStyle}
handleIndicatorStyle={styles.handleIndicatorStyle}
backgroundStyle={tailwind.style("bg-white")}
onClose={handleOnCloseSheet}
>
<BottomSheetView style={tailwind.style("flex-1 bg-white px-5")}>
<View
style={tailwind.style(
"flex flex-row justify-between items-center py-4",
)}
>
{Object.keys(COLORS_COMBO).map(color => {
const isSelected =
JSON.stringify(currentEvent?.color) ===
JSON.stringify(COLORS_COMBO[color as COLORS]);
const handleColorPress = () => {
if (currentEvent !== null) {
setCurrentEvent({
...currentEvent,
color: COLORS_COMBO[color as COLORS],
});
}
};
return (
<Pressable
onPress={handleColorPress}
style={[
tailwind.style(
"h-7 w-7 flex items-center justify-center rounded-full",
),
isSelected
? {
...tailwind.style("border-[2px]"),
...{
borderColor: COLORS_COMBO[color as COLORS].bg,
},
}
: {},
]}
key={color}
>
<View
style={[
tailwind.style("h-5 w-5 rounded-full"),
{ backgroundColor: COLORS_COMBO[color as COLORS].bg },
]}
/>
</Pressable>
);
})}
</View>
<BottomSheetTextInput
onChangeText={handleOnChangeText}
placeholder="Event title"
placeholderTextColor={"rgba(0,0,0,0.3)"}
style={tailwind.style(
"flex flex-row items-center text-xl leading-tight h-10 text-black",
)}
onBlur={() => sheetRef?.current?.snapToIndex(-1)}
enablesReturnKeyAutomatically
returnKeyType="done"
onSubmitEditing={handleAddEventPress}
value={currentEvent?.title}
// @ts-ignore Avoid textinput props
ref={inputRef}
/>
<View style={tailwind.style("flex flex-row items-center mt-6")}>
<View style={tailwind.style("h-5 w-5 rounded-md bg-[#1C6EE9]")} />
<Text style={styles.bottomSheetText}>sandeep@timeless.co</Text>
</View>
<View style={tailwind.style("flex flex-row items-center mt-6")}>
<CalendarIcon />
<Text style={styles.bottomSheetText}>
{formatDate(selectedDate)}
</Text>
</View>
<View style={tailwind.style("flex flex-row items-center mt-6")}>
<ClockIcon />
<Text style={styles.bottomSheetText}>
{currentEvent?.startTime} → {currentEvent?.endTime}
</Text>
<View style={styles.totalTimeContainer}>
<Text style={[styles.totalTimeText]}>
{currentEvent?.totalTime
? formatTimeIntoMins(currentEvent?.totalTime)
: null}
</Text>
</View>
</View>
<View style={tailwind.style("flex flex-row items-center mt-6")}>
<LocationIcon />
<BottomSheetTextInput
placeholder="Location"
onChangeText={handleOnChangeLocation}
placeholderTextColor={"rgba(0,0,0,0.3)"}
style={[
styles.locationTextInput,
tailwind.style("flex flex-row items-center text-black"),
]}
onBlur={() => sheetRef?.current?.snapToIndex(-1)}
enablesReturnKeyAutomatically
returnKeyType="done"
onSubmitEditing={handleAddEventPress}
value={currentEvent?.location}
// @ts-ignore Avoid textinput props
ref={locationInputRef}
/>
</View>
</BottomSheetView>
</BottomSheet>

It is pretty straightforward. It has a set of Input fields to get the Event Title, location, color selection with date (selected date in the Calendar) and time which is predefined from the gesture interaction.

Bottom Sheet Interaction

Event Component

The final piece of this interaction is to render the Created event in the correct Time Segment.

We will have our events stored inside the Zustand store.

const eventStore = useEventStore();

We will map the events and render the Event component if the event date matches the selected date.

{eventStore.events.map(event => {
return event.date === selectedDate ? (
<EventComponent
key={event.date + event.startTime + event.endTime}
event={event}
/>
) : null;
})}

The component takes the whole Event data.

type EventComponentProps = {
event: CalendarEvent;
};

const EventComponent = (props: EventComponentProps) => {
const { event } = props;
const isEvent30Mins = event.height === 30;
return (
<Animated.View
key={event.date + event.title + event.height + event.startTime}
style={[
tailwind.style(
"absolute flex flex-row w-full pl-3 justify-between my-[1px] pr-3 left-15 overflow-hidden",
`w-[${SEGMENT_WIDTH}px]`,
isEvent30Mins
? { alignItems: "center", borderRadius: 10 }
: "pt-3 items-start rounded-xl",
),
{
backgroundColor: event.color.bg,
height: convertMinutesToPixels(event.height) - 2,
transform: [{ translateY: convertMinutesToPixels(event.translateY) }],
},
]}
>
<Animated.Text
style={[
tailwind.style("text-white font-medium"),
styles.eventText,
{ color: event.color.text },
]}
>
{event.title}
</Animated.Text>
<Animated.Text
style={[
tailwind.style("text-white font-semibold"),
styles.bottomSheetTotalTimeText,
{
color: event.color.text,
},
]}
>
{formatTimeIntoMins(event.totalTime)}
</Animated.Text>
</Animated.View>
);
};

This component simply renders an Event, and there are some style level differences where we need to check if it is a 30-min event.

There are three properties which are set using the event object data, the backgroundColor, the height and the pixels it has to be translated. We have the helper functions written and discussed before, convertMinutesToPixels, use it to find the translated value.

Final Screen

Finally, we get these beautiful Events rendered on the ScrollView.

This wraps the whole Event Creation Interaction.

Just saying, this is a very basic POC, there are many other things to consider when moving this to production level, like overlapping of events, manual scroll when gesture hits the end of the screen, and so on…

This blog is part of the UI Interaction List, check out my other blogs on various other Interactions here.

UI Interactions With React Native and Reanimated

26 stories

This is Karthik from Timeless

I hope you found this blog post helpful and informative. If you have any feedback or suggestions, please leave a comment below. I’d love to hear your thoughts and opinions on the topic.

And if you have any ideas for future blog posts, please let me know! I’m constantly looking for new and interesting topics to explore.

Thank you for reading, and I look forward to hearing from you. :)

--

--