在这里插入图片描述

通知设置是个看起来简单,但细节很多的功能。用户要能控制接收哪些通知,用什么方式提醒。更重要的是,这些开关之间有联动关系,关掉总开关,其他开关也要跟着禁用。今天咱们就来聊聊,怎么把通知设置做得既好用又合理。

通知设置的层次结构

通知设置不是一堆开关堆在一起,而是有层次的。

总开关是推送通知,控制是否接收任何通知。这是最顶层的开关,关掉它,其他所有通知都收不到了。

通知类型包括新闻更新、突发新闻、收藏更新。这些是具体的通知内容,用户可以选择只接收某些类型。但前提是总开关要打开。

提醒方式包括声音和振动。这些控制通知来了怎么提醒用户。同样,总开关关了,这些设置也没意义。

这种层次结构很重要,让用户能快速理解每个开关的作用。

为什么用StatefulWidget

通知设置页面需要管理多个开关的状态,所以用StatefulWidget:

class NotificationSettingsScreen extends StatefulWidget {
  const NotificationSettingsScreen({super.key});

  
  State<NotificationSettingsScreen> createState() => 
      _NotificationSettingsScreenState();
}

有人可能会问,为什么不用Provider?因为通知设置是页面级的状态,不需要跨页面共享。用StatefulWidget更简单,不用引入额外的依赖。

如果以后要把设置保存到本地,或者在其他页面显示通知状态,那时候再改成Provider也不迟。

状态变量的定义

先定义所有开关的状态:

class _NotificationSettingsScreenState extends State<NotificationSettingsScreen> {
  bool _pushEnabled = true;
  bool _newsUpdates = true;
  bool _breakingNews = true;
  bool _favorites = false;
  bool _soundEnabled = true;
  bool _vibrationEnabled = true;

每个开关对应一个bool变量。变量名要见名知意,一看就知道是什么开关。

_pushEnabled是总开关,默认打开。大部分用户都希望接收通知,所以默认值是true。

_newsUpdates和**_breakingNews默认打开,因为这是新闻应用的核心功能。_favorites**默认关闭,因为不是所有用户都会用收藏功能。

_soundEnabled和**_vibrationEnabled**默认打开,这是最常见的提醒方式。

默认值的选择很重要,要符合大部分用户的习惯。

页面整体结构

通知设置页面用ListView包含多个分组:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('通知设置'),
    ),
    body: ListView(
      children: [
        SwitchListTile(...),  // 总开关
        const Divider(),
        Padding(...),         // 分组标题
        SwitchListTile(...),  // 通知类型
        SwitchListTile(...),
        SwitchListTile(...),
        const Divider(),
        Padding(...),         // 分组标题
        SwitchListTile(...),  // 提醒方式
        SwitchListTile(...),
      ],
    ),
  );
}

ListView而不是Column,因为内容可能超出屏幕高度。用Divider分隔不同分组,用Padding包裹分组标题。

这个结构清晰,用户一眼就能看出哪些开关是一组的。

总开关的实现

总开关是最重要的,单独放在最上面:

SwitchListTile(
  title: const Text('推送通知'),
  subtitle: const Text('接收应用推送通知'),
  value: _pushEnabled,
  onChanged: (value) {
    setState(() {
      _pushEnabled = value;
    });
  },
)

SwitchListTile是带开关的ListTile,Flutter提供的标准组件。title是主标题,subtitle是副标题,解释这个开关的作用。

value绑定到**_pushEnabled**,开关的状态和变量同步。onChanged是开关切换时的回调,在里面调用setState更新状态。

注意这里没有任何联动逻辑,只是简单地更新状态。联动逻辑在其他开关的onChanged里处理。

分组标题的实现

分组标题用Padding包裹Text:

Padding(
  padding: const EdgeInsets.all(16),
  child: Text(
    '通知类型',
    style: TextStyle(
      fontSize: 14,
      fontWeight: FontWeight.bold,
      color: Theme.of(context).colorScheme.primary,
    ),
  ),
)

padding: 16让标题不贴边,视觉上更舒服。fontSize: 14比正文小一点,表示这是标题。fontWeight: FontWeight.bold加粗更醒目。

color: primary使用主题色,和应用风格一致。这样深色模式下颜色也会自动适配。

通知类型开关的联动

通知类型的开关要和总开关联动:

SwitchListTile(
  title: const Text('新闻更新'),
  subtitle: const Text('接收最新新闻推送'),
  value: _newsUpdates,
  onChanged: _pushEnabled
      ? (value) {
          setState(() {
            _newsUpdates = value;
          });
        }
      : null,
)

关键在onChanged的处理。如果**_pushEnabled是true,onChanged是一个正常的回调函数。如果_pushEnabled**是false,onChanged是null。

onChanged为null时,开关会自动禁用,显示为灰色,用户点击没反应。这是Flutter的标准行为,不用自己写禁用逻辑。

这种联动很直观:总开关关了,其他开关自动变灰,用户一眼就能看出它们不可用。

三元运算符的妙用

注意这里用了三元运算符:

onChanged: _pushEnabled ? (value) { ... } : null

这比if-else简洁多了。如果用if-else:

onChanged: () {
  if (_pushEnabled) {
    return (value) {
      setState(() {
        _newsUpdates = value;
      });
    };
  } else {
    return null;
  }
}()

代码又长又难读。三元运算符一行搞定,清晰明了。

突发新闻开关

突发新闻的实现和新闻更新一样:

SwitchListTile(
  title: const Text('突发新闻'),
  subtitle: const Text('接收重要突发新闻'),
  value: _breakingNews,
  onChanged: _pushEnabled
      ? (value) {
          setState(() {
            _breakingNews = value;
          });
        }
      : null,
)

这里有个细节:subtitle写的是"接收重要突发新闻",而不是"接收突发新闻"。加个"重要",暗示这种通知不会太频繁,用户更愿意打开。

文案很重要,好的文案能提高用户的接受度。

收藏更新开关

收藏更新默认关闭,因为不是所有用户都会用:

SwitchListTile(
  title: const Text('收藏更新'),
  subtitle: const Text('收藏的新闻有更新时通知'),
  value: _favorites,
  onChanged: _pushEnabled
      ? (value) {
          setState(() {
            _favorites = value;
          });
        }
      : null,
)

subtitle解释得很清楚:“收藏的新闻有更新时通知”。用户一看就懂,不会困惑这个开关是干什么的。

提醒方式的开关

提醒方式包括声音和振动:

SwitchListTile(
  title: const Text('声音'),
  value: _soundEnabled,
  onChanged: _pushEnabled
      ? (value) {
          setState(() {
            _soundEnabled = value;
          });
        }
      : null,
)

注意这里没有subtitle,因为"声音"两个字已经很清楚了,不需要额外解释。

有subtitle的开关占两行,没有subtitle的占一行。合理使用subtitle,让页面既清晰又紧凑。

振动开关也一样:

SwitchListTile(
  title: const Text('振动'),
  value: _vibrationEnabled,
  onChanged: _pushEnabled
      ? (value) {
          setState(() {
            _vibrationEnabled = value;
          });
        }
      : null,
)

开关联动的逻辑分析

咱们的联动逻辑很简单:总开关关了,其他开关禁用。但实际项目中,联动可能更复杂。

场景一:关闭总开关时,同时关闭所有子开关

onChanged: (value) {
  setState(() {
    _pushEnabled = value;
    if (!value) {
      _newsUpdates = false;
      _breakingNews = false;
      _favorites = false;
      _soundEnabled = false;
      _vibrationEnabled = false;
    }
  });
}

这样用户重新打开总开关时,需要重新选择要接收哪些通知。有些应用这么做,但用户体验不太好。

场景二:记住子开关的状态

Map<String, bool> _savedStates = {};

onChanged: (value) {
  setState(() {
    if (!value) {
      // 保存当前状态
      _savedStates = {
        'newsUpdates': _newsUpdates,
        'breakingNews': _breakingNews,
        'favorites': _favorites,
        'soundEnabled': _soundEnabled,
        'vibrationEnabled': _vibrationEnabled,
      };
    } else {
      // 恢复之前的状态
      _newsUpdates = _savedStates['newsUpdates'] ?? true;
      _breakingNews = _savedStates['breakingNews'] ?? true;
      _favorites = _savedStates['favorites'] ?? false;
      _soundEnabled = _savedStates['soundEnabled'] ?? true;
      _vibrationEnabled = _savedStates['vibrationEnabled'] ?? true;
    }
    _pushEnabled = value;
  });
}

这样用户关闭总开关再打开,子开关还是原来的状态。体验更好,但代码更复杂。

咱们的应用用最简单的方式:只禁用,不修改状态。用户重新打开总开关,子开关还是原来的状态。

数据持久化

通知设置应该保存到本地,下次打开应用还能记住。用SharedPreferences:

import 'package:shared_preferences/shared_preferences.dart';

Future<void> _loadSettings() async {
  final prefs = await SharedPreferences.getInstance();
  setState(() {
    _pushEnabled = prefs.getBool('pushEnabled') ?? true;
    _newsUpdates = prefs.getBool('newsUpdates') ?? true;
    _breakingNews = prefs.getBool('breakingNews') ?? true;
    _favorites = prefs.getBool('favorites') ?? false;
    _soundEnabled = prefs.getBool('soundEnabled') ?? true;
    _vibrationEnabled = prefs.getBool('vibrationEnabled') ?? true;
  });
}

Future<void> _saveSettings() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setBool('pushEnabled', _pushEnabled);
  await prefs.setBool('newsUpdates', _newsUpdates);
  await prefs.setBool('breakingNews', _breakingNews);
  await prefs.setBool('favorites', _favorites);
  await prefs.setBool('soundEnabled', _soundEnabled);
  await prefs.setBool('vibrationEnabled', _vibrationEnabled);
}

initState里加载,在onChanged里保存:


void initState() {
  super.initState();
  _loadSettings();
}

onChanged: _pushEnabled
    ? (value) {
        setState(() {
          _newsUpdates = value;
        });
        _saveSettings();
      }
    : null

这样设置就能持久化了。

用Provider管理通知设置

如果通知设置要在多个页面使用,可以改用Provider:

class NotificationProvider extends ChangeNotifier {
  bool _pushEnabled = true;
  bool _newsUpdates = true;
  bool _breakingNews = true;
  bool _favorites = false;
  bool _soundEnabled = true;
  bool _vibrationEnabled = true;
  
  bool get pushEnabled => _pushEnabled;
  bool get newsUpdates => _newsUpdates;
  bool get breakingNews => _breakingNews;
  bool get favorites => _favorites;
  bool get soundEnabled => _soundEnabled;
  bool get vibrationEnabled => _vibrationEnabled;
  
  NotificationProvider() {
    _loadSettings();
  }
  
  Future<void> setPushEnabled(bool value) async {
    _pushEnabled = value;
    await _saveSettings();
    notifyListeners();
  }
  
  Future<void> setNewsUpdates(bool value) async {
    _newsUpdates = value;
    await _saveSettings();
    notifyListeners();
  }
  
  // 其他setter方法...
}

页面里用Consumer:

Consumer<NotificationProvider>(
  builder: (context, provider, child) {
    return SwitchListTile(
      title: const Text('推送通知'),
      value: provider.pushEnabled,
      onChanged: (value) {
        provider.setPushEnabled(value);
      },
    );
  },
)

Provider的好处是状态全局共享,其他页面也能访问。比如在首页显示通知状态,或者在详情页根据设置决定是否发送通知。

通知权限的处理

Android和iOS都需要用户授权才能发送通知。可以用permission_handler插件:

import 'package:permission_handler/permission_handler.dart';

Future<void> _requestNotificationPermission() async {
  final status = await Permission.notification.status;
  
  if (status.isDenied) {
    final result = await Permission.notification.request();
    if (result.isGranted) {
      // 权限已授予
    } else if (result.isPermanentlyDenied) {
      // 用户永久拒绝,引导去设置页面
      _showPermissionDialog();
    }
  }
}

void _showPermissionDialog() {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('需要通知权限'),
      content: const Text('请在系统设置中开启通知权限'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            openAppSettings();
            Navigator.pop(context);
          },
          child: const Text('去设置'),
        ),
      ],
    ),
  );
}

用户打开总开关时,先检查权限:

onChanged: (value) async {
  if (value) {
    await _requestNotificationPermission();
  }
  setState(() {
    _pushEnabled = value;
  });
}

通知的实际发送

设置只是第一步,还要能真的发送通知。可以用flutter_local_notifications插件:

import 'package:flutter_local_notifications/flutter_local_notifications.dart';

final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
    FlutterLocalNotificationsPlugin();

Future<void> _initNotifications() async {
  const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
  const iosSettings = DarwinInitializationSettings();
  const settings = InitializationSettings(
    android: androidSettings,
    iOS: iosSettings,
  );
  
  await flutterLocalNotificationsPlugin.initialize(settings);
}

Future<void> _showNotification(String title, String body) async {
  const androidDetails = AndroidNotificationDetails(
    'news_channel',
    '新闻通知',
    channelDescription: '接收最新新闻推送',
    importance: Importance.high,
    priority: Priority.high,
  );
  
  const iosDetails = DarwinNotificationDetails();
  
  const details = NotificationDetails(
    android: androidDetails,
    iOS: iosDetails,
  );
  
  await flutterLocalNotificationsPlugin.show(
    0,
    title,
    body,
    details,
  );
}

根据用户的设置决定是否发送:

Future<void> sendNewsNotification(String title, String body) async {
  if (!_pushEnabled || !_newsUpdates) return;
  
  await _showNotification(title, body);
}

通知的分组管理

Android支持通知分组,可以把相同类型的通知归到一起:

const androidDetails = AndroidNotificationDetails(
  'news_channel',
  '新闻通知',
  groupKey: 'news_group',
  setAsGroupSummary: false,
);

还可以创建一个汇总通知:

const summaryDetails = AndroidNotificationDetails(
  'news_channel',
  '新闻通知',
  groupKey: 'news_group',
  setAsGroupSummary: true,
);

这样多条新闻通知会自动折叠,不会刷屏。

通知的声音和振动

根据用户设置控制声音和振动:

final androidDetails = AndroidNotificationDetails(
  'news_channel',
  '新闻通知',
  playSound: _soundEnabled,
  enableVibration: _vibrationEnabled,
  sound: _soundEnabled 
      ? const RawResourceAndroidNotificationSound('notification')
      : null,
);

自定义通知声音需要把音频文件放在**android/app/src/main/res/raw/**目录下。

定时通知

可以设置定时发送通知:

await flutterLocalNotificationsPlugin.zonedSchedule(
  0,
  '早报',
  '今日新闻精选',
  _nextInstanceOfTime(8, 0),  // 每天早上8点
  const NotificationDetails(
    android: AndroidNotificationDetails(
      'daily_channel',
      '每日推送',
    ),
  ),
  androidAllowWhileIdle: true,
  uiLocalNotificationDateInterpretation:
      UILocalNotificationDateInterpretation.absoluteTime,
  matchDateTimeComponents: DateTimeComponents.time,
);

tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
  final now = tz.TZDateTime.now(tz.local);
  var scheduledDate = tz.TZDateTime(
    tz.local,
    now.year,
    now.month,
    now.day,
    hour,
    minute,
  );
  
  if (scheduledDate.isBefore(now)) {
    scheduledDate = scheduledDate.add(const Duration(days: 1));
  }
  
  return scheduledDate;
}

一些实用的扩展

通知设置还可以加很多实用功能。

免打扰时段

TimeOfDay _doNotDisturbStart = const TimeOfDay(hour: 22, minute: 0);
TimeOfDay _doNotDisturbEnd = const TimeOfDay(hour: 7, minute: 0);

bool _isInDoNotDisturbPeriod() {
  final now = TimeOfDay.now();
  // 判断当前时间是否在免打扰时段
  return _isTimeBetween(now, _doNotDisturbStart, _doNotDisturbEnd);
}

通知频率限制

DateTime? _lastNotificationTime;
final _minInterval = const Duration(minutes: 30);

bool _canSendNotification() {
  if (_lastNotificationTime == null) return true;
  return DateTime.now().difference(_lastNotificationTime!) > _minInterval;
}

通知预览

ElevatedButton(
  onPressed: () {
    _showNotification('测试通知', '这是一条测试通知');
  },
  child: const Text('发送测试通知'),
)

常见问题

开关状态不同步:可能是忘记调用setState。确保每次修改状态都调用setState。

通知发不出来:检查权限是否授予,通知渠道是否创建,设置是否正确。

联动逻辑不生效:检查onChanged是否正确处理了null的情况。

写在最后

通知设置看起来简单,但要做好不容易。开关联动、数据持久化、权限处理、实际发送,每个环节都要考虑周全。

最重要的是用户体验。开关要清晰,联动要合理,文案要友好。这些细节做好了,用户才会愿意打开通知。

代码写完了,记得多测试。不同的开关组合会不会有问题?关闭总开关再打开,状态对不对?通知能不能正常发送?这些场景都要测试到。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

Logo

更多推荐