铃声选择器是闹钟应用中很重要的一个功能。用户设置闹钟时,能选择自己喜欢的铃声,这直接影响起床体验。咱们这次要实现一个功能完整的铃声选择器,不仅能选择内置铃声,还能预览播放、调节音量,甚至支持导入自定义铃声。

说实话,做这个功能的时候,我一直在想怎么让用户能快速找到合适的铃声。最后决定用分类筛选、搜索、预览播放这几个功能组合起来,让选择过程既快速又直观。
请添加图片描述

铃声数据模型

首先定义铃声的数据结构,这是整个功能的基础。

/// 铃声模型
class Ringtone {
  final String id;
  final String name;
  final String path;
  final RingtoneType type;
  final int duration;
  final bool isCustom;
  
  const Ringtone({
    required this.id,
    required this.name,
    required this.path,
    required this.type,
    required this.duration,
    this.isCustom = false,
  });
}

enum RingtoneType {
  gentle, energetic, nature, classic, custom,
}

模型字段设计:id用来唯一标识铃声,name是显示给用户的名称,path是音频文件路径。type字段把铃声分成轻柔、活力、自然、经典、自定义几个类别,方便用户按喜好筛选。duration记录铃声时长,isCustom标记是否为用户导入的铃声。

枚举类型的使用:用enum定义铃声类型比用字符串更安全,编译时就能检查错误,而且IDE能提供自动补全。

铃声控制器基础

接下来实现铃声管理的控制器,负责加载和播放铃声。

import 'package:get/get.dart';
import 'package:audioplayers/audioplayers.dart';
import '../models/ringtone.dart';

class RingtoneController extends GetxController {
  final _audioPlayer = AudioPlayer();
  final ringtones = <Ringtone>[].obs;
  final customRingtones = <Ringtone>[].obs;
  final currentPlayingId = Rxn<String>();
  final isPlaying = false.obs;

GetX控制器:继承GetxController让咱们能用GetX的依赖注入和生命周期管理。

响应式变量:ringtones和customRingtones用obs包裹,UI能自动响应数据变化。currentPlayingId用Rxn表示可空的响应式变量,记录当前播放的铃声ID。isPlaying标记播放状态,UI可以据此显示播放或停止图标。

AudioPlayer实例:用audioplayers包来处理音频播放,这个包在Flutter中很成熟,支持多种音频格式。

  
  void onInit() {
    super.onInit();
    _loadBuiltInRingtones();
    _loadCustomRingtones();
    _setupAudioPlayerListeners();
  }
  
  void _setupAudioPlayerListeners() {
    _audioPlayer.onPlayerStateChanged.listen((state) {
      isPlaying.value = state == PlayerState.playing;
      if (state == PlayerState.completed) {
        currentPlayingId.value = null;
      }
    });
  }

初始化流程:onInit在控制器创建时调用,这里加载内置铃声和自定义铃声,然后设置播放器监听。

播放状态监听:监听播放器状态变化,更新isPlaying变量。当播放完成时,清除currentPlayingId,这样UI上的播放按钮会恢复到初始状态。这种自动状态同步让代码更简洁。

  void _loadBuiltInRingtones() {
    ringtones.value = [
      const Ringtone(
        id: 'gentle_wake',
        name: '轻柔唤醒',
        path: 'assets/ringtones/gentle_wake.mp3',
        type: RingtoneType.gentle,
        duration: 30,
      ),
      const Ringtone(
        id: 'morning_breeze',
        name: '晨风',

内置铃声列表:预定义几个内置铃声,涵盖不同类型。path指向assets目录,这些文件需要在pubspec.yaml中声明。

const构造函数:Ringtone用const构造,这些对象在编译时就创建好了,运行时不需要重新分配内存,性能更好。

播放控制实现

实现铃声的播放和停止功能。

<function_calls>

articles/10_flutter_for_openharmony高级闹钟app实战+铃声选择器实现/flutter_for_openharmony高级闹钟app实战+铃声选择器实现.md

  Future<void> playRingtone(String ringtoneId, {double volume = 0.7}) async {
    try {
      if (currentPlayingId.value == ringtoneId) {
        await stopRingtone();
        return;
      }
      
      await stopRingtone();
      final ringtone = _findRingtoneById(ringtoneId);
      if (ringtone == null) return;

播放逻辑设计:如果点击的是正在播放的铃声,就停止播放,这样用户可以通过再次点击来停止。否则先停止当前播放,再播放新铃声,避免多个铃声同时响。

查找铃声:_findRingtoneById在内置和自定义铃声中查找,找不到就返回null。这种防御性编程能避免因数据不一致导致的崩溃。

      await _audioPlayer.setVolume(volume);
      await _audioPlayer.play(AssetSource(ringtone.path));
      currentPlayingId.value = ringtoneId;
    } catch (e) {
      Get.snackbar('错误', '播放铃声失败: $e');
    }
  }
  
  Future<void> stopRingtone() async {
    await _audioPlayer.stop();
    currentPlayingId.value = null;
  }

音量设置:先设置音量再播放,volume参数默认0.7,这是个比较舒适的预览音量。

错误处理:用try-catch捕获播放异常,通过snackbar提示用户。在音频播放中,文件不存在、格式不支持等问题都可能导致异常。

停止播放:stopRingtone停止播放并清除currentPlayingId,这个方法会被多处调用,所以单独提取出来。

铃声选择器UI

现在实现铃声选择器的界面部分。

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '../../controllers/ringtone_controller.dart';

class RingtonePickerPage extends StatefulWidget {
  final String? selectedRingtoneId;
  
  const RingtonePickerPage({super.key, this.selectedRingtoneId});
  
  
  State<RingtonePickerPage> createState() => _RingtonePickerPageState();
}

页面参数:selectedRingtoneId传入当前选中的铃声ID,这样可以在列表中高亮显示当前选择。

StatefulWidget的选择:虽然用GetX也能管理状态,但对于筛选类型这种临时的UI状态,用StatefulWidget的setState更直接。

class _RingtonePickerPageState extends State<RingtonePickerPage> {
  final _controller = Get.find<RingtoneController>();
  late String? _selectedId;
  RingtoneType? _filterType;
  
  
  void initState() {
    super.initState();
    _selectedId = widget.selectedRingtoneId;
  }

状态变量:_selectedId记录用户选择的铃声,初始值从widget参数获取。_filterType用于筛选铃声类型,null表示显示全部。

Get.find获取控制器:RingtoneController应该在应用启动时就注册好了,这里直接find获取实例。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('选择铃声'),
        actions: [
          TextButton(
            onPressed: () => Get.back(result: _selectedId),
            child: const Text('确定', style: TextStyle(color: Colors.white)),
          ),
        ],
      ),
      body: Column(
        children: [

AppBar设计:右上角的确定按钮返回选中的铃声ID,用Get.back的result参数传递结果。

布局结构:用Column垂直布局,上面是筛选条件,下面是铃声列表。这种结构很常见,用户一眼就能理解。

筛选功能实现

添加铃声类型的筛选功能。

  Widget _buildFilterChips() {
    return Container(
      padding: EdgeInsets.all(16.w),
      child: SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        child: Row(
          children: [
            FilterChip(
              label: const Text('全部'),
              selected: _filterType == null,
              onSelected: (_) => setState(() => _filterType = null),
            ),
            SizedBox(width: 8.w),

FilterChip组件:Material Design的筛选芯片,selected状态会改变样式。点击后用setState更新_filterType并刷新UI。

横向滚动:用SingleChildScrollView包裹,scrollDirection设为horizontal,这样筛选项多了也不会挤在一起。

            FilterChip(
              label: const Text('轻柔'),
              selected: _filterType == RingtoneType.gentle,
              onSelected: (_) => setState(() => _filterType = RingtoneType.gentle),
            ),
            SizedBox(width: 8.w),
            FilterChip(
              label: const Text('活力'),
              selected: _filterType == RingtoneType.energetic,
              onSelected: (_) => setState(() => _filterType = RingtoneType.energetic),
            ),

筛选选项:每个类型对应一个FilterChip,selected根据_filterType判断。onSelected回调中更新筛选类型。

间距处理:芯片之间用SizedBox分隔,8.w的宽度让视觉上有呼吸感。

铃声列表展示

实现铃声列表的构建逻辑。

  Widget _buildRingtoneList() {
    return Obx(() {
      final allRingtones = [
        ..._controller.ringtones,
        ..._controller.customRingtones,
      ];
      
      final filteredRingtones = _filterType == null
          ? allRingtones
          : allRingtones.where((r) => r.type == _filterType).toList();

Obx响应式构建:用Obx包裹让列表能响应ringtones和customRingtones的变化,比如用户添加了自定义铃声。

合并列表:用展开运算符合并内置和自定义铃声,这样用户能在一个列表中看到所有铃声。

筛选逻辑:_filterType为null时显示全部,否则用where过滤出匹配类型的铃声。

      if (filteredRingtones.isEmpty) {
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.music_off, size: 64.sp, color: Colors.grey),
              SizedBox(height: 16.h),
              Text('暂无铃声', style: TextStyle(color: Colors.grey, fontSize: 16.sp)),
            ],
          ),
        );
      }

空状态处理:筛选后列表为空时,显示友好的提示。用music_off图标和灰色文字,让用户知道这不是错误,只是没有匹配的铃声。

居中布局:用Center和Column让空状态提示在屏幕中央,视觉上更平衡。

      return ListView.builder(
        padding: EdgeInsets.symmetric(horizontal: 16.w),
        itemCount: filteredRingtones.length,
        itemBuilder: (context, index) {
          final ringtone = filteredRingtones[index];
          return _buildRingtoneItem(ringtone);
        },
      );
    });
  }

ListView.builder:用builder模式构建列表,只创建可见的item,性能更好。即使有上百个铃声也不会卡顿。

padding设置:左右各16.w的padding让列表内容不会紧贴屏幕边缘,视觉上更舒适。

铃声项设计

实现单个铃声项的UI。

  Widget _buildRingtoneItem(Ringtone ringtone) {
    final isSelected = _selectedId == ringtone.id;
    final isPlaying = _controller.currentPlayingId.value == ringtone.id;
    
    return Card(
      margin: EdgeInsets.only(bottom: 12.h),
      elevation: isSelected ? 4 : 1,
      child: ListTile(
        contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
        leading: Container(
          width: 48.w,
          height: 48.w,

状态判断:isSelected判断是否为当前选中的铃声,isPlaying判断是否正在播放。这两个状态会影响UI样式。

Card的elevation:选中的铃声elevation更高,产生浮起的视觉效果,让用户清楚知道当前选择。

ListTile布局:ListTile是Material Design的标准列表项组件,自动处理点击波纹、间距等细节。

          decoration: BoxDecoration(
            color: isSelected 
                ? Theme.of(context).primaryColor.withOpacity(0.1)
                : Colors.grey.withOpacity(0.1),
            borderRadius: BorderRadius.circular(24.r),
          ),
          child: Icon(
            _getRingtoneIcon(ringtone.type),
            color: isSelected ? Theme.of(context).primaryColor : Colors.grey,
          ),
        ),

leading图标设计:用圆形容器包裹图标,选中时用主题色背景,未选中时用灰色。这种视觉反馈很直观。

图标选择:_getRingtoneIcon根据铃声类型返回不同图标,轻柔用月亮,活力用闪电,自然用树叶,让用户一眼识别类型。

        title: Text(
          ringtone.name,
          style: TextStyle(
            fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
          ),
        ),
        subtitle: Text('${ringtone.duration}秒 · ${_getTypeLabel(ringtone.type)}'),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (ringtone.isCustom)
              Icon(Icons.star, size: 16.sp, color: Colors.amber),

标题样式:选中的铃声用粗体显示,增强视觉区分度。

副标题信息:显示时长和类型,用中点分隔,信息密度适中。

自定义标记:如果是用户导入的铃声,显示星标图标,让用户知道这是自己的铃声。

            SizedBox(width: 8.w),
            IconButton(
              icon: Icon(isPlaying ? Icons.stop_circle : Icons.play_circle),
              color: Theme.of(context).primaryColor,
              onPressed: () => _controller.playRingtone(ringtone.id),
            ),
          ],
        ),
        onTap: () => setState(() => _selectedId = ringtone.id),
      ),
    );
  }

播放按钮:根据isPlaying状态切换图标,播放时显示停止图标,停止时显示播放图标。点击调用控制器的playRingtone方法。

点击选择:点击整个ListTile会选中这个铃声,更新_selectedId并刷新UI。这种大面积的点击区域比只能点按钮更友好。

音量预览组件

添加音量调节功能,让用户能调整预览音量。

class VolumePreviewWidget extends StatefulWidget {
  final double initialVolume;
  final ValueChanged<double>? onVolumeChanged;
  
  const VolumePreviewWidget({
    super.key,
    this.initialVolume = 0.7,
    this.onVolumeChanged,
  });
  
  
  State<VolumePreviewWidget> createState() => _VolumePreviewWidgetState();
}

组件参数:initialVolume设置初始音量,默认0.7。onVolumeChanged回调让父组件能响应音量变化。

独立组件:把音量控制做成独立组件,可以在多个地方复用,比如铃声选择器、闹钟编辑器等。

class _VolumePreviewWidgetState extends State<VolumePreviewWidget> {
  late double _volume;
  
  
  void initState() {
    super.initState();
    _volume = widget.initialVolume;
  }
  
  
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.all(16.w),

状态管理:_volume记录当前音量值,初始化时从widget参数获取。

Card容器:用Card包裹让音量控制区域有明显的视觉边界,和其他内容区分开。

      child: Padding(
        padding: EdgeInsets.all(16.w),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(Icons.volume_up, size: 24.sp),
                SizedBox(width: 8.w),
                Text('预览音量', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
                const Spacer(),
                Text('${(_volume * 100).toInt()}%', style: TextStyle(fontSize: 16.sp, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold)),
              ],
            ),

标题行设计:左侧音量图标和文字,右侧显示百分比。Spacer让两边内容分别靠左右对齐。

百分比显示:把0-1的音量值转换成0-100的百分比,用主题色高亮显示,让用户清楚知道当前音量。

            SizedBox(height: 8.h),
            SliderTheme(
              data: SliderTheme.of(context).copyWith(
                trackHeight: 4.h,
                thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8.r),
                overlayShape: RoundSliderOverlayShape(overlayRadius: 16.r),
              ),
              child: Slider(
                value: _volume,
                min: 0.0,
                max: 1.0,
                divisions: 20,

Slider定制:用SliderTheme定制滑块样式,trackHeight设置轨道高度,thumbShape设置滑块大小,overlayShape设置点击波纹大小。

divisions参数:设为20意味着有21个档位(0-20),每个档位对应5%的音量变化,这个粒度对预览来说刚好。

                onChanged: (value) {
                  setState(() => _volume = value);
                  widget.onVolumeChanged?.call(value);
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

音量变化处理:onChanged回调中更新_volume并刷新UI,同时调用父组件的回调。这样父组件能实时获取音量变化,比如立即应用到正在播放的铃声。

可选回调:用?.call确保onVolumeChanged为null时不会报错,这让组件使用更灵活。

自定义铃声导入

实现用户导入自己音乐作为铃声的功能。

import 'package:file_picker/file_picker.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';

extension RingtoneControllerCustom on RingtoneController {
  Future<void> importCustomRingtone() async {
    try {
      final result = await FilePicker.platform.pickFiles(
        type: FileType.audio,
        allowMultiple: false,
      );

Extension扩展:用extension给RingtoneController添加方法,而不是直接修改类,这样代码组织更清晰。

FilePicker使用:type设为FileType.audio确保只能选择音频文件,allowMultiple设为false表示一次只能选一个。

      if (result == null || result.files.isEmpty) return;
      
      final file = result.files.first;
      if (file.path == null) {
        Get.snackbar('错误', '无法读取文件');
        return;
      }
      
      final fileSize = File(file.path!).lengthSync();
      if (fileSize > 10 * 1024 * 1024) {
        Get.snackbar('错误', '文件大小不能超过10MB');
        return;
      }

文件验证:检查用户是否选择了文件,path是否为null。然后检查文件大小,限制在10MB以内,避免占用太多存储空间。

用户提示:验证失败时用snackbar提示用户,说明具体原因,而不是静默失败。

      final id = 'custom_${DateTime.now().millisecondsSinceEpoch}';
      final name = file.name.replaceAll(RegExp(r'\.[^.]+$'), '');
      
      final appDir = await getApplicationDocumentsDirectory();
      final customDir = Directory('${appDir.path}/ringtones');
      if (!await customDir.exists()) {
        await customDir.create(recursive: true);
      }
      
      final newPath = '${customDir.path}/$id.${file.extension}';
      await File(file.path!).copy(newPath);

ID生成:用时间戳生成唯一ID,前缀custom_表示这是自定义铃声。

文件名处理:用正则表达式去掉扩展名,得到铃声名称。

文件复制:把用户选择的文件复制到应用目录,而不是直接引用原路径。这样即使用户删除了原文件,铃声也能正常使用。recursive: true确保父目录不存在时会自动创建。

      final ringtone = Ringtone(
        id: id,
        name: name,
        path: newPath,
        type: RingtoneType.custom,
        duration: 30,
        isCustom: true,
      );
      
      customRingtones.add(ringtone);
      await _saveCustomRingtones();
      Get.snackbar('成功', '铃声已导入');
    } catch (e) {
      Get.snackbar('错误', '导入失败: $e');
    }
  }

创建铃声对象:用复制后的路径创建Ringtone对象,type设为custom,isCustom设为true。duration默认30秒,实际可以通过音频解析获取准确时长。

保存和提示:添加到customRingtones列表,调用_saveCustomRingtones持久化,最后提示用户导入成功。整个过程用try-catch包裹,任何异常都会被捕获并提示。

铃声持久化

实现自定义铃声的保存和加载。

  Future<void> _saveCustomRingtones() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      final jsonList = customRingtones.map((r) => r.toJson()).toList();
      await prefs.setString('custom_ringtones', jsonEncode(jsonList));
    } catch (e) {
      debugPrint('保存自定义铃声失败: $e');
    }
  }
  
  Future<void> loadCustomRingtones() async {
    try {
      final prefs = await SharedPreferences.getInstance();

SharedPreferences存储:用SharedPreferences保存铃声列表,这是Flutter中最简单的持久化方案,适合存储少量数据。

JSON序列化:把Ringtone对象列表转换成JSON字符串存储,加载时再反序列化。这要求Ringtone类实现toJson和fromJson方法。

      final jsonString = prefs.getString('custom_ringtones');
      if (jsonString == null) return;
      
      final jsonList = jsonDecode(jsonString) as List;
      customRingtones.value = jsonList
          .map((json) => Ringtone.fromJson(json as Map<String, dynamic>))
          .toList();
    } catch (e) {
      debugPrint('加载自定义铃声失败: $e');
    }
  }
}

加载逻辑:从SharedPreferences读取JSON字符串,解码成List,然后map转换成Ringtone对象列表。

错误处理:加载失败时只打印日志,不影响应用启动。这样即使数据损坏,用户也能正常使用内置铃声。

铃声删除功能

添加删除自定义铃声的功能。

  Future<void> deleteCustomRingtone(String id) async {
    try {
      final ringtone = customRingtones.firstWhere((r) => r.id == id);
      
      final file = File(ringtone.path);
      if (await file.exists()) {
        await file.delete();
      }
      
      customRingtones.removeWhere((r) => r.id == id);
      await _saveCustomRingtones();
      Get.snackbar('成功', '铃声已删除');
    } catch (e) {
      Get.snackbar('错误', '删除失败: $e');
    }
  }

删除流程:先找到铃声对象,删除文件,从列表移除,保存更新后的列表,最后提示用户。

文件删除:删除实际的音频文件,避免留下垃圾文件占用存储空间。先检查文件是否存在,避免文件已被删除时报错。

列表更新:用removeWhere从列表中移除,这会触发UI更新。然后调用_saveCustomRingtones持久化变更。

在闹钟编辑器中集成

把铃声选择器集成到闹钟编辑器中。

// 在AlarmEditorPage中修改铃声选择部分
Widget _buildRingtoneSelector() {
  return Card(
    child: ListTile(
      leading: const Icon(Icons.music_note),
      title: const Text('铃声'),
      subtitle: Obx(() {
        final controller = Get.find<RingtoneController>();
        final ringtone = controller.ringtones
            .firstWhereOrNull((r) => r.id == _ringtoneId);
        return Text(ringtone?.name ?? _ringtoneId);
      }),

Obx响应式显示:用Obx包裹subtitle,这样铃声列表变化时能自动更新显示的名称。

查找铃声名称:根据_ringtoneId查找对应的铃声对象,显示name字段。找不到时显示ID本身,这是一种降级处理。

      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Obx(() {
            final controller = Get.find<RingtoneController>();
            final isPlaying = controller.currentPlayingId.value == _ringtoneId;
            return IconButton(
              icon: Icon(isPlaying ? Icons.stop : Icons.play_arrow),
              onPressed: () => controller.playRingtone(_ringtoneId),
            );
          }),
          const Icon(Icons.chevron_right),
        ],
      ),

预览按钮:在trailing添加播放按钮,让用户不进入选择器也能预览当前铃声。根据播放状态切换图标。

导航图标:chevron_right提示用户可以点击进入选择器,这是Material Design的常见模式。

      onTap: () async {
        final result = await Get.to<String>(
          () => RingtonePickerPage(selectedRingtoneId: _ringtoneId),
        );
        if (result != null) {
          setState(() => _ringtoneId = result);
        }
      },
    ),
  );
}

导航和结果处理:用Get.to打开铃声选择器,传入当前选中的ID。await等待用户选择,result不为null时更新_ringtoneId。

类型安全:Get.to指定返回类型,这样result的类型是String?,编译器能做类型检查。

总结

这篇文章咱们实现了一个功能完整的铃声选择器。从数据模型到UI组件,从音频播放到文件管理,每个环节都考虑得很周到。铃声分类、搜索、预览、导入这些功能让用户体验很完整。

说实话,做这个功能让我对音频处理有了更深的理解。audioplayers包虽然简单,但足够应对大多数场景。文件管理要注意权限和存储空间,用户导入的文件要复制到应用目录更可靠。UI设计要考虑各种状态,播放中、选中、空列表都要有合适的视觉反馈。

如果你也在做类似的功能,建议重点关注音频资源的管理和释放,这直接影响应用性能。另外用户体验的细节,比如预览音量、播放状态指示、友好的错误提示,这些看似小事,实际上决定了产品的品质。

欢迎加入OpenHarmony跨平台开发社区交流:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐