Всем привет! Меня зовут Хатам. Я работаю в компании Neti. Когда-то я был верстальщиком сайтов, но мне хотелось развиваться дальше. Поэтому я изучил React и научился делать веб-приложения, а затем решил попробовать свои силы в мобильной разработке. В этой статье я делюсь примерами решений, к которым пришел, работая над задачей одного из клиентов. Надеюсь, что мой опыт будет кому-то полезен.
Проект
Мобильное приложение, предназначенное для быстрой отправки сообщений о проблемах и опасных факторах для жизни и здоровья людей на территории производственного предприятия.
Пользователи: сотрудники компании, подрядчики и гости предприятия.
Платформы: Android, IOS.
Стек технологий: React Native, MobX.
Задача
Возможности, которые необходимо было реализовать:
Запись голоса;
Отмена записи свайпом вправо с анимацией и изменением иконки;
Воспроизведение записанного звука.
Также записанный голос должен был воспроизводиться в админке в браузере.
Вот как это должно работать:
![](https://habrastorage.org/getpro/habr/upload_files/708/233/19b/70823319bb74672dbb79ff8195c19b97.gif)
Решение
Я приступил к поиску библиотеки. Из найденных вариантов самым популярным оказался 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