Каждый пользователь мобильного приложения предпочитает иметь возможность выбора между несколькими темами. Наличие доступных тем также очень важно для улучшения пользовательского опыта. Итак, как мы можем сделать это эффективно во Flutter? Как мы можем установить различные конфигурации для каждой темы? Эта статья поможет вам правильно понять это, а также я немного расскажу о Stream
в Dart.
Демонстрация
Давайте посмотрим на наше финальное приложение.
Как видно из GIF, наше приложение позволяет переключаться между темной и светлой темой. Кроме того, иконка на плавающей кнопке действия меняется динамически. А выбранная тема сохраняется, что можно наблюдать при каждом запуске приложения.
Зависимости
Прежде чем приступить к работе над кодом, давайте сначала включим некоторые внешние пакеты, которые нам понадобятся. Включите flutter_bloc, shared_preferences и equatable в качестве зависимостей в файл pubspec.yaml
.
Давайте кодировать 👨💻
Поскольку это очень простое приложение, в этой статье я не буду касаться архитектуры приложения. Вы можете посмотреть другие мои статьи, если хотите узнать об архитектуре приложений.
Конфигурации темы
Сначала запустите новый проект flutter и избавьтесь от стандартного приложения счетчика. Затем в папке lib создайте файл app_theme.dart
.
import 'package:flutter/material.dart';
abstract class AppTheme {
static ThemeData get lightTheme => ThemeData(
scaffoldBackgroundColor: Colors.white,
textTheme: ThemeData.light().textTheme.copyWith(
bodyText1: const TextStyle(
fontSize: 25,
color: Colors.black,
),
caption: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 15,
color: Colors.black,
),
),
);
static ThemeData get darkTheme => ThemeData(
scaffoldBackgroundColor: Colors.blueGrey.shade800,
textTheme: ThemeData.dark().textTheme.copyWith(
bodyText1: const TextStyle(
fontSize: 25,
color: Colors.white,
),
caption: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 15,
color: Colors.white,
),
),
);
}
Хранилище слоев данных/тем
Затем создайте файл theme_repository.dart
в папке lib и вставьте в него следующий код.
import 'package:shared_preferences/shared_preferences.dart';
abstract class ThemePersistence {
Stream<CustomTheme> getTheme();
Future<void> saveTheme(CustomTheme theme);
void dispose();
}
enum CustomTheme { light, dark }
class ThemeRepository implements ThemePersistence {
ThemeRepository({
required SharedPreferences sharedPreferences,
}) : _sharedPreferences = sharedPreferences;
final SharedPreferences _sharedPreferences;
@override
Stream<CustomTheme> getTheme() {}
@override
Future<void> saveTheme(CustomTheme theme) {}
@override
void dispose() {}
}
Как вы видите, мы создали абстрактный класс ThemePersistence
, и другой класс ThemeRepository
, который реализует абстрактный класс. Также мы создали перечисление CustomTheme
, которое имеет два значения – light
и dark
, поскольку именно эти темы будут у нашего приложения. Класс ThemeRepository
зависит от экземпляра SharedPreferences
, который мы будем использовать для сохранения тем.
Также мы видим, что getTheme()
возвращает Stream
, а не Future
. Основная причина использования Stream в том, что слушатели этого потока могут немедленно получить уведомление о смене темы, и нам не нужно вызывать метод getTheme()
снова и снова.
Если бы у нас была реализация Future
для getTheme()
, то после каждого обновления темы нам пришлось бы вызывать метод getTheme()
, что не идеально.
getTheme() – это одноразовая передача данных, то есть он должен быть вызван только один раз, а любые изменения должны быть переданы в виде потока, который будут слушать слушатели.
Теперь мы добавим код в наши методы getTheme()
и saveTheme()
.
import 'package:shared_preferences/shared_preferences.dart';
abstract class ThemePersistence {
Stream<CustomTheme> getTheme();
Future<void> saveTheme(CustomTheme theme);
void dispose();
}
enum CustomTheme { light, dark }
class ThemeRepository implements ThemePersistence {
ThemeRepository({
required SharedPreferences sharedPreferences,
}) : _sharedPreferences = sharedPreferences;
final SharedPreferences _sharedPreferences;
static const _kThemePersistenceKey = '__theme_persistence_key__';
final _controller = StreamController<CustomTheme>();
Future<void> _setValue(String key, String value) =>
_sharedPreferences.setString(key, value);
@override
Stream<CustomTheme> getTheme() => _controller.stream;
@override
Future<void> saveTheme(CustomTheme theme) {
_controller.add(theme);
return _setValue(_kThemePersistenceKey, theme.name);
}
@override
void dispose() => _controller.close();
}
Мы инициализировали StreamController
, который будет действовать как менеджер для нашей Stream<CustomTheme>
. Метод saveTheme()
прост. Сначала он добавляет тему, которую мы хотим сохранить, в поток, а затем вызывает _setValue()
, который сохранит тему. Метод _setValue()
использует API из SharedPreferences
для сохранения выбранной темы.
Все выглядит хорошо. Но если посмотреть внимательно, то метод getTheme()
выдает поток из контроллера. Но изначально, при запуске приложения, в контроллере не будет никакого потока. Как же тогда с этим быть?
Решение простое. Мы можем добавить тело конструктора, которое будет выполняться, как только класс ThemeRepository
будет инстанцирован. Кроме того, getTheme()
– это метод, который будет вызван на самой ранней стадии. Поэтому нам нужно убедиться, что до вызова метода getTheme()
в контроллере есть какое-то значение потока. Поэтому добавьте метод _init()
в тело конструктора и инстанцируйте ThemeRepository
в файле main.dart
позже. Тогда окончательная версия нашего файла theme_repository.dart
будет выглядеть следующим образом:
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
abstract class ThemePersistence {
Stream<CustomTheme> getTheme();
Future<void> saveTheme(CustomTheme theme);
void dispose();
}
enum CustomTheme { light, dark }
class ThemeRepository implements ThemePersistence {
ThemeRepository({
required SharedPreferences sharedPreferences,
}) : _sharedPreferences = sharedPreferences {
_init();
}
final SharedPreferences _sharedPreferences;
static const _kThemePersistenceKey = '__theme_persistence_key__';
final _controller = StreamController<CustomTheme>();
String? _getValue(String key) {
try {
return _sharedPreferences.getString(key);
} catch (_) {
return null;
}
}
Future<void> _setValue(String key, String value) =>
_sharedPreferences.setString(key, value);
void _init() {
final themeString = _getValue(_kThemePersistenceKey);
if (themeString != null) {
if (themeString == CustomTheme.light.name) {
_controller.add(CustomTheme.light);
} else {
_controller.add(CustomTheme.dark);
}
} else {
_controller.add(CustomTheme.light);
}
}
@override
Stream<CustomTheme> getTheme() async* {
yield* _controller.stream;
}
@override
Future<void> saveTheme(CustomTheme theme) {
_controller.add(theme);
return _setValue(_kThemePersistenceKey, theme.name);
}
@override
void dispose() => _controller.close();
}
Также вы могли заметить, что я использовал перечисление CustomTheme
, которое я создал в этом файле, вместо использования ThemeMode
, которое доступно во Flutter. Причина просто в том, чтобы не включать пакет material в слой данных нашего проекта, поскольку ThemeMode
приходит из пакета material. На мой взгляд, компоненты пакета material связаны с презентационным слоем, а не со слоем данных.
Cubits/ViewModel/Управление состоянием
Теперь мы создадим класс ThemeCubit
и ThemeClass
, которые будут отвечать за обработку состояний. Создайте папку в каталоге lib и назовите ее them_cubit
. Внутри theme_cubit
создайте два файла dart – theme_cubit.dart
и theme_state.dart
.
theme_state.dart
part of 'theme_cubit.dart';
class ThemeState extends Equatable {
const ThemeState({this.themeMode = ThemeMode.light}); // Default theme = light theme
final ThemeMode themeMode;
// `copyWith()` method allows us to emit brand new instance of ThemeState
ThemeState copyWith({ThemeMode? themeMode}) => ThemeState(
themeMode: themeMode ?? this.themeMode,
);
@override
List<Object?> get props => [themeMode];
}
theme_cubit.dart
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:theme_switching_demo/theme_repository.dart';
part 'theme_state.dart';
class ThemeCubit extends Cubit<ThemeState> {
ThemeCubit({
required ThemePersistence themeRepository,
}) : _themeRepository = themeRepository,
super(const ThemeState());
final ThemePersistence _themeRepository;
late StreamSubscription<CustomTheme> _themeSubscription;
static late bool _isDarkTheme; // used to determine if the current theme is dark
void getCurrentTheme() {
// Since `getTheme()` returns a stream, we listen to the output
_themeSubscription = _themeRepository.getTheme().listen(
(customTheme) {
if (customTheme.name == CustomTheme.light.name) {
// Since, `customTheme` is light, we set `_isDarkTheme` to false
_isDarkTheme = false;
emit(state.copyWith(themeMode: ThemeMode.light));
} else {
// Since, `customTheme` is dark, we set `_isDarkTheme` to true
_isDarkTheme = true;
emit(state.copyWith(themeMode: ThemeMode.dark));
}
},
);
}
void switchTheme() {
if (_isDarkTheme) {
// Since, currentTheme is dark, after switching we want light theme to
// be persisted.
_themeRepository.saveTheme(CustomTheme.light);
} else {
// Since, currentTheme is light, after switching we want dark theme to
// be persisted.
_themeRepository.saveTheme(CustomTheme.dark);
}
}
@override
Future<void> close() {
_themeSubscription.cancel();
_themeRepository.dispose();
return super.close();
}
}
Код не требует пояснений, и я добавил все необходимые пояснения через комментарии в вышеприведенный код. Обязательно просмотрите их.
main.dart
Давайте добавим немного кода в файл main.dart
.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:theme_switching_demo/app.dart';
import 'package:theme_switching_demo/theme_repository.dart';
Future<void> main() async {
// required when using any plugin. In our case, it's shared_preferences
WidgetsFlutterBinding.ensureInitialized();
// Creating an instance of ThemeRepository that will invoke the `_init()` method
// and populate the stream controller in the repository.
final themeRepository = ThemeRepository(
sharedPreferences: await SharedPreferences.getInstance(),
);
runApp(App(themeRepository: themeRepository));
}
Виджет приложения
Далее создайте файл app.dart
в каталоге lib и вставьте в него следующий код.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:theme_switching_demo/home_page.dart';
import 'package:theme_switching_demo/theme_cubit/theme_cubit.dart';
import 'package:theme_switching_demo/theme_repository.dart';
import 'package:theme_switching_demo/themes.dart';
class App extends StatelessWidget {
const App({required this.themeRepository, super.key});
final ThemeRepository themeRepository;
@override
Widget build(BuildContext context) {
return RepositoryProvider.value(
value: themeRepository,
child: BlocProvider(
create: (context) => ThemeCubit(
themeRepository: context.read<ThemeRepository>(),
)..getCurrentTheme(),
child: const AppView(),
),
);
}
}
class AppView extends StatelessWidget {
const AppView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<ThemeCubit, ThemeState>(
builder: (context, state) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: AppTheme.lightTheme, // If ThemeMode is ThemeMode.light, this is selected as app's theme
darkTheme: AppTheme.darkTheme, // If ThemeMode is ThemeMode.dark, this is selected as app's theme
// The themeMode is the most important property in showing
// proper theme. The value comes from ThemeState class.
themeMode: state.themeMode,
home: const HomePage(),
);
},
);
}
}
Домашняя страница
Создайте файл home_page.dart
и добавьте следующий код.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:theme_switching_demo/theme_cubit/theme_cubit.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Theme Switching Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Follow me on my socials',
style: Theme.of(context).textTheme.bodyText1,
// Depending on the current theme, the text is also rendered properly
// If the theme is dark, text is white in color else black
),
const SizedBox(height: 10),
Text(
'https://github.com/Biplab-Dutta',
style: Theme.of(context).textTheme.caption,
),
const SizedBox(height: 10),
Text(
'https://twitter.com/b_plab98',
style: Theme.of(context).textTheme.caption,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<ThemeCubit>().switchTheme(),
tooltip: 'Switch Theme',
child: context.read<ThemeCubit>().state.themeMode == ThemeMode.light
? const Icon(Icons.dark_mode)
: const Icon(Icons.light_mode),
),
);
}
}
Другие решения
Существует множество других способов сделать то же самое, что я показал в этой статье. Вы также можете использовать hydrated_bloc вместо cubits для управления состоянием и его сохранения. Однако я хотел показать вам, как можно было бы сделать сохранение, если бы вы работали с другими решениями для управления состоянием, кроме flutter_bloc.
Заключение
Эта статья показала, как мы можем включить функцию переключения и сохранения тем в наше приложение Flutter, используя лучшие практики. Я надеюсь, что вы все почерпнули из моей статьи, и если у вас есть какие-либо замечания, напишите мне в комментариях. Я обязательно выложу еще одну статью через несколько дней.
Если вы хотите увидеть несколько проектов Flutter с правильной архитектурой, следите за мной на GitHub. Я также активен в Twitter @b_plab.
Исходный код проекта в этой статье
Кредит:
raywenderlich.com за изображение обложки.
Мои социальные сети:
-
GitHub
-
LinkedIn
-
Twitter
До следующего раза, счастливого кодинга!!! 👨💻
– Биплаб Дутта