Всем привет! Меня зовут Хатам. Я работаю в компании 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 

https://www.npmjs.com/package/react-native-slider-custom

Комментарии (0)