Всем привет! Меня зовут Хатам. Я работаю в компании Neti. Когда-то я был верстальщиком сайтов, но мне хотелось развиваться дальше. Поэтому я изучил React и научился делать веб-приложения, а затем решил попробовать свои силы в мобильной разработке. В этой статье я делюсь примерами решений, к которым пришел, работая над задачей одного из клиентов. Надеюсь, что мой опыт будет кому-то полезен.
Проект
Мобильное приложение, предназначенное для быстрой отправки сообщений о проблемах и опасных факторах для жизни и здоровья людей на территории производственного предприятия.
Пользователи: сотрудники компании, подрядчики и гости предприятия.
Платформы: Android, IOS.
Стек технологий: React Native, MobX.
Задача
Возможности, которые необходимо было реализовать:
Запись голоса;
Отмена записи свайпом вправо с анимацией и изменением иконки;
Воспроизведение записанного звука.
Также записанный голос должен был воспроизводиться в админке в браузере.
Вот как это должно работать:

Решение
Я приступил к поиску библиотеки. Из найденных вариантов самым популярным оказался react-native-audio-recorder-player. Основным его плюсом является принцип «2 в 1», то есть запись и воспроизведение записанного аудио.
Сделал два компонента: AudioRecorder и AudioPlayer.
Начнем с AudioRecorder.tsx. Библиотеку нашел, но она не умеет по свайпу отменять запись. Это надо было реализовать самостоятельно.
После долгих поисков и раздумий в голову пришла идея: а что, если этот свайп сделать слайдером? Почему бы и нет: у слайдера есть состояние, которое позволяет плавно анимировать нужный элемент и прописать отмену на определенной позиции. Но чтобы сделать такую кнопку, какая нам нужна, слайдер должен быть нестандартный. Поэтому подключил библиотеку react-native-slider-custom. Скрыл полосу слайдера, сделал кнопку «запись/отмена» с динамичными иконками, сделал абсолютное позиционирование поверх двух блоков: текста и продолжительности записи. Все готово, идея сработала ?
AudioRecorder.tsx
import React, { useEffect, useState } from 'react'; import { PermissionsAndroid, Platform, StyleSheet, Text, ToastAndroid, View } from 'react-native'; import { observer } from 'mobx-react-lite'; import Slider from 'react-native-slider-custom'; import { AudioEncoderAndroidType, AudioSet, AudioSourceAndroidType, AVEncoderAudioQualityIOSType, AVEncodingOption, } from 'react-native-audio-recorder-player'; import { Colors } from '../../../styles/Colors'; import MicroPhoneIcon from '../../svg/MicroPhoneIcon'; import { TextStyle } from '../../../styles/TextStyle'; import RecordingIcon from '../../svg/RecordingIcon'; import TrashIcon from '../../svg/TrashIcon'; import { playerStyles } from './playerStyles'; import { useStores } from '../../../hooks/use-stores'; const CANCEL_RECORDING_SLIDER_VALUE = 0.8; const MAX_AUDIO_DURATION = 180000; // ms = 180000=3m interface IAudioRecorderProps {} const AudioRecorder: React.FC<IAudioRecorderProps> = observer(({}) => { const { audioRecPlayStore: store } = useStores(); const [androidGranted, setAndroidGranted] = useState(false); const [recordSlidingValue, setRecordSlidingValue] = useState(0); const [recordTime, setRecordTime] = useState('00:00:00'); // Effects useEffect(() => { (async () => { if (Platform.OS === 'android') { const hasPermissionWrite = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE); const hasPermissionRecord = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO); if (hasPermissionRecord && hasPermissionWrite) { setAndroidGranted(true); } else { setAndroidGranted(false); } } else { setAndroidGranted(true); } })(); }, []); // Handlers const handleSlidingStart = () => { onStartRecord(); }; const handleSlidingChange = (value: number) => { setRecordSlidingValue(value); }; const handleSlidingComplete = (value: number) => { onStopRecord(value); }; // Actions const onStartRecord = async () => { if (!androidGranted) { if (Platform.OS === 'android') { const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO); if (granted !== PermissionsAndroid.RESULTS.GRANTED) { ToastAndroid.show('Использование микрофона запрещена', ToastAndroid.LONG); return false; } } if (Platform.OS === 'android') { const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE); if (granted !== PermissionsAndroid.RESULTS.GRANTED) { ToastAndroid.show('Запись в хранилише запрещено', ToastAndroid.LONG); return false; } } setAndroidGranted(true); } else { const path = Platform.select({ ios: 'akkerman_voice_message.m4a', android: 'sdcard/akkerman_voice_message.mp4', }); const audioSet: AudioSet = { AudioEncoderAndroid: AudioEncoderAndroidType.AAC, AudioSourceAndroid: AudioSourceAndroidType.MIC, AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high, AVNumberOfChannelsKeyIOS: 2, AVFormatIDKeyIOS: AVEncodingOption.aac, }; await store.audioRecorderPlayer.startRecorder(path, audioSet); store.setIsRecording(true); store.audioRecorderPlayer.addRecordBackListener((e: any) => { setRecordTime(store.audioRecorderPlayer.mmssss(Math.floor(e.current_position))); // Stop and save recording if (e.current_position > MAX_AUDIO_DURATION) { onStopRecord(0); } return; }); } return; }; const onStopRecord = async (value: number) => { const result = await store.audioRecorderPlayer.stopRecorder(); setRecordSlidingValue(0); store.setIsRecording(false); store.audioRecorderPlayer.removeRecordBackListener(); if (value > CANCEL_RECORDING_SLIDER_VALUE) { // Cancel recording store.setAudio(null); } else { // Save recording if (!result.includes('stop')) { store.setAudio({ duration: recordTime, uri: result }); } } }; // Renders return ( <View> <Slider value={recordSlidingValue} minimumTrackTintColor="transparent" maximumTrackTintColor="transparent" onSlidingStart={handleSlidingStart} onSlidingComplete={handleSlidingComplete} onValueChange={handleSlidingChange} customThumb={ store.isRecording ? ( <View style={styles.animatedIconsContainer}> <View style={[styles.animatedIconsBG, { opacity: recordSlidingValue }]} /> <View style={[styles.animatedIconsWrap]}> <MicroPhoneIcon color={Colors.white} style={[styles.animatedIcon, { opacity: 1 - recordSlidingValue }]} /> <TrashIcon style={[styles.animatedIcon, { opacity: recordSlidingValue > CANCEL_RECORDING_SLIDER_VALUE ? 1 : recordSlidingValue }]} /> </View> </View> ) : ( <MicroPhoneIcon /> ) } thumbStyle={[ playerStyles.button, store.isRecording && playerStyles.buttonShadow, { backgroundColor: recordSlidingValue > CANCEL_RECORDING_SLIDER_VALUE ? Colors.error : Colors.primary }, ]} style={styles.swipeableButton} animateTransitions /> <View style={[styles.recorder, { opacity: 1 - recordSlidingValue }]}> <View style={{ flexDirection: 'row', alignItems: 'center' }}> <Text style={{ ...TextStyle.caption, color: Colors.whiteMedium, marginLeft: 40 + 8 }}> {store.isRecording ? 'Вправо для отмены' : 'Зажмите для записи'} </Text> </View> {store.isRecording && ( <View style={{ flexDirection: 'row', alignItems: 'center' }}> <Text style={{ ...TextStyle.body1, marginRight: 10 }}>{recordTime}</Text> <RecordingIcon /> </View> )} </View> </View> ); }); export default AudioRecorder; const styles = StyleSheet.create({ recorder: { height: 40, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, swipeableButton: { position: 'absolute', top: 0, left: 0, width: '100%', height: 40, zIndex: 1, }, animatedIconsContainer: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', }, animatedIconsBG: { position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, backgroundColor: Colors.error, }, animatedIconsWrap: { position: 'relative', width: 24, height: 24, }, animatedIcon: { position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, }, });
Для реализации отмены записи воспользовался методом onSlidingComplete у библиотеки слайдера, который срабатывает при отпускании ползунка. Позицию отмены решил сделать 80%. Вынес в константу для удобства CANCEL_RECORDING_SLIDER_VALUE, равный 0.8, потому что слайдер по умолчанию работает от 0 до 1.
При отпускании кнопки записи (ползунка слайдера) вызываем обработчик handleSlidingComplete. Он, в свою очередь, вызывает функцию onStopRecord, передавая туда позицию слайдера на момент отпускания. В функции onStopRecord в зависимости от позиции слайдера делаем проверку: сохранить запись или отменить запись. Чтобы определить, какое действие выполнить, прописываем условие value > CANCEL_RECORDING_SLIDER_VALUE. Если значения слайдера при отпускании кнопки записи больше 0.8 — отменяем запись, если меньше — сохраняем.
Вот и все!
AudioPlayer.tsx
import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; import { observer } from 'mobx-react-lite'; import Slider from 'react-native-slider-custom'; import { useFocusEffect } from '@react-navigation/native'; import { useStores } from '../../../hooks/use-stores'; import { Colors } from '../../../styles/Colors'; import { TextStyle } from '../../../styles/TextStyle'; import PlayIcon from '../../svg/PlayIcon'; import PauseIcon from '../../svg/PauseIcon'; import { playerStyles } from './playerStyles'; interface IAudioPlayerProps { audio?: string; } const AudioPlayer: React.FC<IAudioPlayerProps> = observer(({ audio }) => { const { audioRecPlayStore: store } = useStores(); const [currentPositionSec, setCurrentPositionSec] = useState(0); const [currentDurationSec, setCurrentDurationSec] = useState(0); const [playTime, setPlayTime] = useState('00:00:00'); const [isPlaying, setIsPlaying] = useState(false); const [loading, setLoading] = useState(false); useFocusEffect( React.useCallback(() => { return () => { onStopPlay(); }; }, []), ); useEffect(() => { return () => { onStopPlay(); }; }, []); // Actions const onPlayPause = () => { if (!loading) { if (isPlaying) { onPausePlay(); } else { onStartPlay(); } } }; const onStartPlay = useCallback(async () => { const path = Platform.select({ ios: audio ? audio : 'akkerman_voice_message.m4a', android: audio ? audio : 'sdcard/akkerman_voice_message.mp4', }); setLoading(true); await store.audioRecorderPlayer.startPlayer(path); setLoading(false); setIsPlaying(true); store.audioRecorderPlayer.addPlayBackListener((e: any) => { if (e.current_position === e.duration) { setIsPlaying(false); onStopPlay(); } setCurrentPositionSec(e.current_position); setCurrentDurationSec(e.duration); setPlayTime(store.audioRecorderPlayer.mmssss(Math.floor(e.current_position))); return; }); }, [store.audioRecorderPlayer]); const onPausePlay = async () => { await store.audioRecorderPlayer.pausePlayer(); setIsPlaying(false); }; const onStopPlay = async () => { store.audioRecorderPlayer.stopPlayer(); store.audioRecorderPlayer.removePlayBackListener(); }; // Renders return ( <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <Pressable style={[playerStyles.button, { marginRight: 12 }]} onPress={onPlayPause}> {loading ? <ActivityIndicator size={24} color={Colors.black} /> : isPlaying ? <PauseIcon /> : <PlayIcon />} </Pressable> <View style={{ flex: 1 }}> <Slider maximumValue={Math.floor(currentDurationSec)} value={Math.floor(currentPositionSec)} minimumTrackTintColor={Colors.primary} maximumTrackTintColor={Colors.whiteMedium} thumbStyle={{ width: 12, height: 12, backgroundColor: Colors.primary }} style={{ height: 12 + 8 }} disabled /> <Text style={{ ...TextStyle.caption, color: Colors.white }}>{playTime}</Text> </View> </View> ); }); export default AudioPlayer;
Это обычный кастомный аудиоплеер с некоторыми отличиями библиотеки.
Чтобы рекордер и плеер работали совместно, нужно использовать один и тот же экземпляр плеера. Также для хорошего UX нужно было отключить скролл экрана "Создания сообщения о риске" во время записи голоса, чтобы работал только слайдер. Поэтому сделал MobX стор AudioRecPlayStore.ts.
AudioRecPlayStore.ts
import { makeAutoObservable } from 'mobx'; import AudioRecorderPlayer from 'react-native-audio-recorder-player'; import { Nullable } from '../../../types/CommonTypes'; import { IAudioObject } from './AudioRecPlay'; export class AudioRecPlayStore { isRecording = false; audioRecorderPlayer: AudioRecorderPlayer; audio: Nullable<IAudioObject> = null; constructor() { makeAutoObservable(this); this.audioRecorderPlayer = new AudioRecorderPlayer(); } setIsRecording = (state: boolean) => { this.isRecording = state; }; setAudio = (audio: Nullable<IAudioObject>) => { this.audio = audio; }; clear = () => { this.isRecording = false; this.audio = null; }; }
В сторе есть следующие свойства:
Экземпляр плеера audioRecorderPlayer для переключение из рекордера в плеер и обратно;
Состояние записи isRecording для отключения скролла во время записи.
<ScrollView scrollEnabled={!audioRecPlayStore.isRecording}>
За рендер рекордера или плеера отвечает компонент MessageAudioMessage.tsx.
MessageAudioMessage.tsx
import React, { useEffect } from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { observer } from 'mobx-react-lite'; import { useStores } from '../../../hooks/use-stores'; import InputLabel from '../../InputLabel'; import { messageStyles } from '../messageStyles'; import { Colors } from '../../../styles/Colors'; import { TextStyle } from '../../../styles/TextStyle'; import AudioRecorder from './AudioRecorder'; import AudioPlayer from './AudioPlayer'; import { FIleData } from '../../../modules/message/Message'; import { Nullable } from '../../../types/CommonTypes'; interface IMessageAudioMessageProps { onlyPlayer?: boolean; audio?: Nullable<FIleData>; } const MessageAudioMessage: React.FC<IMessageAudioMessageProps> = observer(({ onlyPlayer, audio }) => { const { audioRecPlayStore: store } = useStores(); useEffect(() => { return () => { store.clear(); }; }, []); const handleRemoveAudio = () => { store.setAudio(null); store.audioRecorderPlayer.stopPlayer(); store.audioRecorderPlayer.removePlayBackListener(); }; return ( <View style={{ ...messageStyles.card, flexDirection: 'row', alignItems: 'center' }}> <View style={{ flex: 1 }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <InputLabel label="Аудиосообщение" /> {!onlyPlayer && !!store.audio && ( <TouchableOpacity onPress={handleRemoveAudio} activeOpacity={0.8}> <Text style={{ ...TextStyle.subtitle, color: Colors.error, letterSpacing: 0 }}>Удалить</Text> </TouchableOpacity> )} </View> {onlyPlayer ? <AudioPlayer audio={audio?.fullUrl || ''} /> : !!store.audio ? <AudioPlayer /> : <AudioRecorder />} </View> </View> ); }); export default MessageAudioMessage;
Были проблемы с воспроизведением записанного голоса на разных платформах из разных платформ: они были несовместимы. Решение есть в самой документации библиотеки рекордера.
сonst audioSet: AudioSet = { AudioEncoderAndroid: AudioEncoderAndroidType.AAC, AudioSourceAndroid: AudioSourceAndroidType.MIC, AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high, AVNumberOfChannelsKeyIOS: 2, AVFormatIDKeyIOS: AVEncodingOption.aac, }; await store.audioRecorderPlayer.startRecorder(path, audioSet);
Создаем объект audioSet с настройками записи аудиофайла и передаем как второй аргумент методу startRecorder. Эти настройки кроссплатформенные, то есть записанный голос на Android/iOS воспроизводится на вебе в нативном html5 плеере и на Android/iOS.
Полезные ресурсы:
https://www.npmjs.com/package/react-native-audio-recorder-player
