From 39dcf3db9cd380022f82b20ef038acd9357f7dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20S=C4=99k?= Date: Tue, 29 Oct 2024 13:20:03 +0100 Subject: [PATCH 1/7] A bit more appealing drum machine example (#185) * feat: a bit more appealing drum machine example * chore: cleanup * chore: some cleanup * feat: ui cleanup --- apps/common-app/package.json | 2 + apps/common-app/src/App.tsx | 62 ++- apps/common-app/src/components/BGGradient.tsx | 46 ++ apps/common-app/src/components/Container.tsx | 37 +- apps/common-app/src/components/Select.tsx | 121 +++++ apps/common-app/src/components/Slider.tsx | 246 ++++++--- apps/common-app/src/components/index.ts | 2 + .../src/examples/DrumMachine/BGGradient.tsx | 0 .../src/examples/DrumMachine/DrumMachine.tsx | 276 +++++----- .../src/examples/DrumMachine/Grid.tsx | 72 +++ .../examples/DrumMachine/NotesHighlight.tsx | 67 +++ .../src/examples/DrumMachine/PatternShape.tsx | 82 +++ .../src/examples/DrumMachine/PlayButton.tsx | 108 ++++ .../src/examples/DrumMachine/constants.ts | 21 + .../DrumMachine/{index.tsx => index.ts} | 0 .../src/examples/DrumMachine/instruments.ts | 24 + .../src/examples/DrumMachine/presets.ts | 510 ++++++++++++++++++ .../src/examples/DrumMachine/types.ts | 37 ++ .../src/examples/DrumMachine/useGestures.ts | 104 ++++ .../src/examples/DrumMachine/usePlayer.tsx | 157 ++++++ .../src/examples/DrumMachine/utils.ts | 31 ++ .../src/examples/Metronome/Metronome.tsx | 23 +- .../src/examples/Oscillator/Oscillator.tsx | 41 +- .../src/examples/SharedUtils/Scheduler.ts | 7 +- .../examples/SharedUtils/soundEngines/Kick.ts | 3 +- apps/common-app/src/examples/index.ts | 8 +- apps/common-app/src/styles.ts | 10 +- apps/common-app/src/utils/withSeparators.ts | 22 + apps/expo-example/app.json | 12 + apps/expo-example/package.json | 2 + .../app/src/main/assets/custom/glyphmap.json | 1 + .../main/assets/fonts/swm-icons-broken.ttf | Bin 0 -> 120812 bytes .../main/assets/fonts/swm-icons-curved.ttf | Bin 0 -> 116832 bytes .../main/assets/fonts/swm-icons-outline.ttf | Bin 0 -> 106232 bytes .../android/link-assets-manifest.json | 21 + .../FabricExample.xcodeproj/project.pbxproj | 25 + .../ios/FabricExample/Info.plist | 9 +- apps/fabric-example/ios/Podfile.lock | 35 +- .../ios/link-assets-manifest.json | 21 + apps/fabric-example/package.json | 2 + apps/fabric-example/react-native.config.js | 6 + .../common/cpp/core/AudioDestinationNode.cpp | 2 +- yarn.lock | 71 +++ 43 files changed, 2042 insertions(+), 284 deletions(-) create mode 100644 apps/common-app/src/components/BGGradient.tsx create mode 100644 apps/common-app/src/components/Select.tsx create mode 100644 apps/common-app/src/examples/DrumMachine/BGGradient.tsx create mode 100644 apps/common-app/src/examples/DrumMachine/Grid.tsx create mode 100644 apps/common-app/src/examples/DrumMachine/NotesHighlight.tsx create mode 100644 apps/common-app/src/examples/DrumMachine/PatternShape.tsx create mode 100644 apps/common-app/src/examples/DrumMachine/PlayButton.tsx create mode 100644 apps/common-app/src/examples/DrumMachine/constants.ts rename apps/common-app/src/examples/DrumMachine/{index.tsx => index.ts} (100%) create mode 100644 apps/common-app/src/examples/DrumMachine/instruments.ts create mode 100644 apps/common-app/src/examples/DrumMachine/presets.ts create mode 100644 apps/common-app/src/examples/DrumMachine/types.ts create mode 100644 apps/common-app/src/examples/DrumMachine/useGestures.ts create mode 100644 apps/common-app/src/examples/DrumMachine/usePlayer.tsx create mode 100644 apps/common-app/src/examples/DrumMachine/utils.ts create mode 100644 apps/common-app/src/utils/withSeparators.ts create mode 100644 apps/fabric-example/android/app/src/main/assets/custom/glyphmap.json create mode 100644 apps/fabric-example/android/app/src/main/assets/fonts/swm-icons-broken.ttf create mode 100644 apps/fabric-example/android/app/src/main/assets/fonts/swm-icons-curved.ttf create mode 100644 apps/fabric-example/android/app/src/main/assets/fonts/swm-icons-outline.ttf create mode 100644 apps/fabric-example/android/link-assets-manifest.json create mode 100644 apps/fabric-example/ios/link-assets-manifest.json create mode 100644 apps/fabric-example/react-native.config.js diff --git a/apps/common-app/package.json b/apps/common-app/package.json index e32ea5c3..7f423fe5 100644 --- a/apps/common-app/package.json +++ b/apps/common-app/package.json @@ -6,6 +6,8 @@ "@react-navigation/native": "*", "@react-navigation/native-stack": "*", "@react-navigation/stack": "*", + "@shopify/react-native-skia": "*", + "@swmansion/icons": "*", "react": "*", "react-dom": "*", "react-native": "*", diff --git a/apps/common-app/src/App.tsx b/apps/common-app/src/App.tsx index 299d66dd..2aa8f596 100644 --- a/apps/common-app/src/App.tsx +++ b/apps/common-app/src/App.tsx @@ -1,38 +1,55 @@ import React from 'react'; import type { FC } from 'react'; +import Animated from 'react-native-reanimated'; import { createStackNavigator } from '@react-navigation/stack'; -import { FlatList, StyleSheet, Text, Pressable } from 'react-native'; +import { + FlatList, + StyleSheet, + Text, + Pressable, + ListRenderItem, +} from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { NavigationContainer, useNavigation } from '@react-navigation/native'; import Container from './components/Container'; -import { Examples, MainStackProps } from './examples'; +import { Example, Examples, MainStackProps } from './examples'; import { layout, colors } from './styles'; +import { Spacer } from './components'; const Stack = createStackNavigator(); +// Our slider component uses the text prop to display shared value +// We need it whitelisted in order to have it "animated". +Animated.addWhitelistedNativeProps({ text: true }); + +const ItemSeparatorComponent = () => ; + const HomeScreen: FC = () => { const navigation = useNavigation(); + const renderItem: ListRenderItem = ({ item }) => ( + navigation.navigate(item.key)} + key={item.key} + style={({ pressed }) => [ + styles.button, + { borderStyle: pressed ? 'solid' : 'dashed' }, + ]} + > + {item.title} + {item.subtitle} + + ); + return ( - + ( - navigation.navigate(item.key)} - key={item.key} - style={({ pressed }) => [ - styles.button, - { borderStyle: pressed ? 'solid' : 'dashed' }, - ]} - > - {item.title} - {item.subtitle} - - )} + renderItem={renderItem} keyExtractor={(item) => item.key} + contentContainerStyle={styles.scrollView} + ItemSeparatorComponent={ItemSeparatorComponent} /> ); @@ -44,8 +61,10 @@ const App: FC = () => { { + const [size, setSize] = useState({ width: 0, height: 0 }); + + const onWrapperLayout = useCallback((event: LayoutChangeEvent) => { + setSize({ + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }); + }, []); + + return ( + + + + + + + + ); +}; + +export default BGGradient; + +const styles = StyleSheet.create({ + wrapper: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + canvas: { + width: '100%', + height: '100%', + }, +}); diff --git a/apps/common-app/src/components/Container.tsx b/apps/common-app/src/components/Container.tsx index 4ffa84fb..335554e1 100644 --- a/apps/common-app/src/components/Container.tsx +++ b/apps/common-app/src/components/Container.tsx @@ -1,29 +1,42 @@ -/* eslint-disable react-native/no-inline-styles */ -import type { PropsWithChildren, FC } from 'react'; -import { StyleProp, ViewStyle } from 'react-native'; +import React, { PropsWithChildren } from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; + +import BGGradient from './BGGradient'; +import { colors } from '../styles'; type ContainerProps = PropsWithChildren<{ style?: StyleProp; centered?: boolean; }>; -const Container: FC = (props) => { +const headerPadding = 120; // eyeballed + +const Container: React.FC = (props) => { const { children, style, centered } = props; + return ( + {children} ); }; export default Container; + +const styles = StyleSheet.create({ + basic: { + flex: 1, + padding: 24, + paddingTop: headerPadding, + backgroundColor: colors.background, + }, + centered: { + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/apps/common-app/src/components/Select.tsx b/apps/common-app/src/components/Select.tsx new file mode 100644 index 00000000..c85e61ca --- /dev/null +++ b/apps/common-app/src/components/Select.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react'; +// @ts-expect-error +import { Icon } from '@swmansion/icons'; +import { ScrollView } from 'react-native-gesture-handler'; +import { Modal, View, Text, Pressable, StyleSheet } from 'react-native'; + +import withSeparators from '../utils/withSeparators'; +import { colors } from '../styles'; +import Spacer from './Spacer'; + +interface SelectProps { + value: T; + options: T[]; + onChange: (value: T) => void; +} + +function Select(props: SelectProps) { + const { options, value, onChange } = props; + const [isModalOpen, setModalOpen] = useState(false); + + const renderSeparator = (index: number) => ( + + ); + + const renderOption = (option: T) => ( + { + onChange(option); + setModalOpen(false); + }} + > + + + + {option} + + + ); + + return ( + <> + setModalOpen(true)}> + + {value} + + + + + + { + setModalOpen(false); + }} + /> + + + {withSeparators(options, renderSeparator, renderOption)} + + + + + ); +} + +export default Select; + +const styles = StyleSheet.create({ + selectBox: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + }, + selectText: { + flex: 1, + color: colors.white, + fontSize: 16, + paddingVertical: 12, + }, + modalBg: { + backgroundColor: colors.modalBackdrop, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + modalSpacer: { + flex: 3, + }, + modalContainer: { + flex: 2, + backgroundColor: colors.background, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 24, + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + }, + separator: { + height: 1, + backgroundColor: colors.separator, + marginHorizontal: 12, + marginVertical: 6, + }, +}); diff --git a/apps/common-app/src/components/Slider.tsx b/apps/common-app/src/components/Slider.tsx index d8b82be8..3e1b2aea 100644 --- a/apps/common-app/src/components/Slider.tsx +++ b/apps/common-app/src/components/Slider.tsx @@ -1,119 +1,205 @@ -import React from 'react'; -import { View, StyleSheet } from 'react-native'; -import { FC, useEffect, useCallback } from 'react'; +import React, { useEffect } from 'react'; +import { + View, + Text, + TextInput, + StyleSheet, + LayoutChangeEvent, +} from 'react-native'; import { GestureDetector, Gesture } from 'react-native-gesture-handler'; import Animated, { runOnJS, + withSpring, useSharedValue, + useAnimatedProps, useAnimatedStyle, } from 'react-native-reanimated'; -import { layout, colors } from '../styles'; +import { colors } from '../styles'; +import Spacer from './Spacer'; interface SliderProps { + min: number; + max: number; + step: number; + value: number; + label?: string; + minLabelWidth?: number; onValueChange: (value: number) => void; - minimumValue: number; - maximumValue: number; - step: number; } -const SLIDER_WIDTH = 300; -const HANDLE_SIZE = 20; -const HANDLE_SPACING = 5; -const MAX_OFFSET = SLIDER_WIDTH - HANDLE_SIZE - 10; +const handleSize = 20; + +function valueToOffset( + value: number, + sliderWidth: number, + min: number, + max: number +): number { + 'worklet'; + + return ((value - min) / (max - min)) * (sliderWidth - handleSize); +} + +function offsetToValue( + offset: number, + sliderWidth: number, + min: number, + max: number +): number { + 'worklet'; -const Slider: FC = (props) => { - const { value, onValueChange, minimumValue, maximumValue, step } = props; + return (offset / (sliderWidth - handleSize)) * (max - min) + min; +} + +function roundToStep(value: number, step: number): number { + 'worklet'; + + return Math.round(value / step) * step; +} + +const AnimatedText = Animated.createAnimatedComponent(TextInput); + +const Slider: React.FC = (props) => { + const { value, onValueChange, min, max, step, label, minLabelWidth } = props; const offset = useSharedValue(0); + const sValue = useSharedValue(0); + const sliderWidth = useSharedValue(0); + + useEffect(() => { + offset.value = valueToOffset(value, sliderWidth.value, min, max); + sValue.value = value; + }, [value, min, max, sliderWidth, offset, sValue]); - const convertOffsetToValue = useCallback( - (offsetValue: number) => { - const newValue = - minimumValue + - (offsetValue / MAX_OFFSET) * (maximumValue - minimumValue); - const steppedValue = Math.round(newValue / step) * step; - const clampedValue = Math.max( - minimumValue, - Math.min(steppedValue, maximumValue) + const pan = Gesture.Pan() + .onChange((event) => { + offset.value = Math.max( + 0, + Math.min(sliderWidth.value - handleSize, offset.value + event.changeX) ); - onValueChange(clampedValue); - }, - [minimumValue, maximumValue, step, onValueChange] - ); + sValue.value = offsetToValue(offset.value, sliderWidth.value, min, max); + }) + .onEnd(() => { + runOnJS(onValueChange)(roundToStep(sValue.value, step)); + }); - const convertValueToOffset = useCallback( - (newValue: number) => { - const fraction = - (newValue - minimumValue) / (maximumValue - minimumValue); - return fraction * MAX_OFFSET; - }, - [minimumValue, maximumValue] - ); + const onSliderLayout = (event: LayoutChangeEvent) => { + sliderWidth.value = event.nativeEvent.layout.width; - const pan = Gesture.Pan().onChange((event) => { - offset.value = - Math.abs(offset.value) <= MAX_OFFSET - ? offset.value + event.changeX <= 0 - ? 0 - : offset.value + event.changeX >= MAX_OFFSET - ? MAX_OFFSET - : offset.value + event.changeX - : offset.value; - - runOnJS(convertOffsetToValue)(offset.value); - }); - - const sliderStyle = useAnimatedStyle(() => { - return { - transform: [{ translateX: offset.value }], - }; - }); + offset.value = valueToOffset( + value, + event.nativeEvent.layout.width, + min, + max + ); - useEffect(() => { - offset.value = convertValueToOffset(value); - }, [ - value, - minimumValue, - maximumValue, - convertOffsetToValue, - offset, - convertValueToOffset, - ]); + sValue.value = value; + }; + + const handleStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: offset.value }], + })); + + const fillStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: offset.value + handleSize / 2 - sliderWidth.value }, + ], + })); + + const valueTextProps = useAnimatedProps(() => ({ + text: `${step < 1 ? roundToStep(sValue.value, step).toFixed(2) : roundToStep(sValue.value, step)}`, + defaultValue: `${roundToStep(sValue.value, step)}`, + })); + + const valueTextStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: withSpring( + sliderWidth.value > 0 && offset.value < 1.5 * handleSize + ? 1.5 * handleSize + : 0 + ), + }, + ], + })); return ( - - - - - + {!!label && ( + <> + + {label} + + + + )} + + + + + + + ); }; const styles = StyleSheet.create({ container: { + flexDirection: 'row', + }, + label: { + fontSize: 16, + color: colors.white, + }, + slider: { + flex: 1, + borderWidth: 1, overflow: 'hidden', - padding: layout.spacing, + height: handleSize, + borderColor: colors.border, + borderRadius: handleSize / 2, }, - sliderTrack: { - width: SLIDER_WIDTH, - height: HANDLE_SIZE + 10, + fill: { + position: 'absolute', + zIndex: -1, + width: '100%', + marginTop: -1, + height: handleSize, + borderRadius: handleSize / 2, backgroundColor: colors.main, - borderRadius: 25, - justifyContent: 'center', - padding: layout.spacing, }, - sliderHandle: { - width: HANDLE_SIZE, - height: HANDLE_SIZE, - backgroundColor: colors.white, - borderRadius: 20, + valueText: { + fontSize: 16, + fontWeight: '600', + color: colors.white, position: 'absolute', - left: HANDLE_SPACING, + left: 0, + right: 0, + top: 0, + bottom: 0, + paddingLeft: 12, + }, + handle: { + marginTop: -1, + marginLeft: -1, + width: handleSize, + height: handleSize, + borderColor: colors.border, + borderRadius: handleSize / 2, + backgroundColor: colors.white, }, }); diff --git a/apps/common-app/src/components/index.ts b/apps/common-app/src/components/index.ts index 35bc36c9..52e9cf67 100644 --- a/apps/common-app/src/components/index.ts +++ b/apps/common-app/src/components/index.ts @@ -4,4 +4,6 @@ export { default as Button } from './Button'; export { default as Slider } from './Slider'; export { default as Spacer } from './Spacer'; export { default as Switch } from './Switch'; +export { default as Select } from './Select'; export { default as Container } from './Container'; +export { default as BGGradient } from './BGGradient'; diff --git a/apps/common-app/src/examples/DrumMachine/BGGradient.tsx b/apps/common-app/src/examples/DrumMachine/BGGradient.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/common-app/src/examples/DrumMachine/DrumMachine.tsx b/apps/common-app/src/examples/DrumMachine/DrumMachine.tsx index 38f27177..5b0997ac 100644 --- a/apps/common-app/src/examples/DrumMachine/DrumMachine.tsx +++ b/apps/common-app/src/examples/DrumMachine/DrumMachine.tsx @@ -1,150 +1,168 @@ -import { Text, View } from 'react-native'; -import { AudioContext } from 'react-native-audio-api'; -import { useState, useRef, useEffect, FC } from 'react'; - -import { Container, Steps, Spacer, Slider, Button } from '../../components'; -import { Sounds, SoundName } from '../../types'; -import { Kick, Clap, HiHat, Scheduler } from '../SharedUtils'; - -const initialBpm = 120; - -const STEPS: Sounds = [ - { name: 'kick', steps: new Array(8).fill(false) }, - { name: 'clap', steps: new Array(8).fill(false) }, - { name: 'hi-hat', steps: new Array(8).fill(false) }, -]; - -const DrumMachine: FC = () => { - const audioContextRef = useRef(null); - const schedulerRef = useRef(null); - const kickRef = useRef(null); - const hiHatRef = useRef(null); - const clapRef = useRef(null); - - const [sounds, setSounds] = useState(STEPS); - const [isPlaying, setIsPlaying] = useState(false); +import { Canvas } from '@shopify/react-native-skia'; +import React, { useState, useCallback } from 'react'; +import { GestureDetector } from 'react-native-gesture-handler'; +import { LayoutChangeEvent, StyleSheet, View } from 'react-native'; + +import { Select, Slider, Spacer, Container } from '../../components'; +import { colors } from '../../styles'; + +import { Pattern, type XYWHRect } from './types'; +import { size, initialBpm } from './constants'; +import NotesHighlight from './NotesHighlight'; +import PatternShape from './PatternShape'; +import useGestures from './useGestures'; +import PlayButton from './PlayButton'; +import usePlayer from './usePlayer'; +import presets from './presets'; +import Grid from './Grid'; + +const defaultPreset = 'Empty'; + +const DrumMachine: React.FC = () => { + const [preset, setPreset] = useState(defaultPreset); const [bpm, setBpm] = useState(initialBpm); - const handleStepClick = (name: SoundName, idx: number) => { - setSounds((prevSounds) => { - const newSounds = [...prevSounds]; - const steps = newSounds.find((sound) => sound.name === name)?.steps; - if (steps) { - steps[idx] = !steps[idx]; + const [patterns, setPatterns] = useState([ + ...presets[defaultPreset].pattern, + ]); + + const player = usePlayer({ + bpm, + patterns, + notesPerBeat: 2, + }); + + const [canvasRect, setCanvasRect] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + + const onPatternChange = useCallback( + (patternIdx: number, stepIdx: number) => { + if (preset !== 'Custom') { + setPreset('Custom'); } - if (schedulerRef.current) { - schedulerRef.current.steps = newSounds; - } - return newSounds; - }); - }; - const handleBpmChange = (newBpm: number) => { - handlePause(); - setBpm(newBpm); - if (schedulerRef.current) { - schedulerRef.current.bpm = newBpm; - } - }; + setPatterns((prevPatterns) => { + const newPatterns = [...prevPatterns].map((pattern, idx) => { + if (idx !== patternIdx) { + return { ...pattern, steps: [...pattern.steps] }; + } - const handlePause = () => { - setIsPlaying(false); - schedulerRef.current?.stop(); - }; + const newPattern = { ...pattern, steps: [...pattern.steps] }; + newPattern.steps[stepIdx] = !newPattern.steps[stepIdx]; - const handlePlayPause = () => { - if (!audioContextRef.current || !schedulerRef.current) { - return; - } - - if (!isPlaying) { - setIsPlaying(true); - schedulerRef.current.start(); - } else { - setIsPlaying(false); - schedulerRef.current.stop(); - } - }; - - const playSound = (name: SoundName, time: number) => { - if (!audioContextRef.current || !schedulerRef.current) { - return; - } - - if (!kickRef.current) { - kickRef.current = new Kick(audioContextRef.current); - } - - if (!hiHatRef.current) { - hiHatRef.current = new HiHat(audioContextRef.current); - } + return newPattern; + }); - if (!clapRef.current) { - clapRef.current = new Clap(audioContextRef.current); - } + return newPatterns; + }); + }, + [preset] + ); - switch (name) { - case 'kick': - kickRef.current.play(time); - break; - case 'hi-hat': - hiHatRef.current.play(time); - break; - case 'clap': - clapRef.current.play(time); - break; - default: - break; - } - }; + const onSetPreset = useCallback( + (newPreset: string) => { + setPreset(newPreset); - useEffect(() => { - if (!audioContextRef.current) { - audioContextRef.current = new AudioContext(); - } + if (newPreset !== 'Custom') { + setBpm(presets[newPreset].bpm); + setPatterns([...presets[newPreset].pattern]); + } + }, + [setPreset] + ); - if (!schedulerRef.current) { - const scheduler = new Scheduler( - initialBpm, - 8, - audioContextRef.current, - STEPS, - playSound - ); - schedulerRef.current = scheduler; + const onPlayPress = useCallback(() => { + if (player.isPlaying) { + player.stop(); + } else { + player.play(); } - - return () => { - schedulerRef.current?.stop(); - audioContextRef.current?.close(); - }; + }, [player]); + + const onCanvasLayout = useCallback((event: LayoutChangeEvent) => { + setCanvasRect({ + x: event.nativeEvent.layout.x, + y: event.nativeEvent.layout.y, + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }); }, []); + const gesture = useGestures({ canvasRect, onPatternChange }); + return ( - -