脱离 Material:定制自己的风格
Flutter 默认使用 Material Design 风格,但你有完全的自由去创造任何视觉风格。本文将带你从「离开 Material」到「搭建自己的设计体系」,一步步实现。
核心思路
Flutter 的 UI 由 Widget 树构成,Widget 只是描述"长什么样"的配置。Material 组件(ElevatedButton、Card、AppBar...)本质上也只是 Widget 的封装。脱离 Material 风格,只需要:
- 不使用 Material 组件,改用基础组件自己组合
- 或者换一套设计体系(Cupertino / 自定义组件库)
- 定义自己的设计令牌(Design Token),统一管理颜色、间距、圆角等
三种路线对比
| 路线 | 适合场景 | 难度 | 工作量 |
|---|---|---|---|
| 深度定制 ThemeData | 想保留 Material 组件,但改外观 | ★☆☆ | 小 |
| 使用 CupertinoApp | iOS 风格即可 | ★★☆ | 小 |
| 完全自建设计体系 | 独特品牌风格、无任何框架痕迹 | ★★★ | 大 |
路线一:深度定制 ThemeData(保留 Material 组件)
如果你只是不喜欢 Material 的默认配色和圆角,但还想用 ElevatedButton、Card 等组件,可以通过 ThemeData 改到"面目全非":
MaterialApp(
theme: ThemeData(
useMaterial3: true,
// 1. 用自定义颜色替换整个色彩方案
colorScheme: const ColorScheme(
brightness: Brightness.light,
primary: Color(0xFFFF6B35), // 橙色品牌主色
onPrimary: Colors.white,
primaryContainer: Color(0xFFFFDCC8),
onPrimaryContainer: Color(0xFF8B3E15),
secondary: Color(0xFF2EC4B6), // 青色辅助色
onSecondary: Colors.white,
surface: Color(0xFFFAFAFA),
onSurface: Color(0xFF1A1A2E),
error: Color(0xFFE63946),
onError: Colors.white,
outline: Color(0xFFE0E0E0),
),
// 2. 全局圆角改大 → 更柔和的风格
cardTheme: CardThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
elevation: 0,
color: Color(0xFFF5F5F5),
),
// 3. 按钮改成圆角胶囊形
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
),
),
// 4. 输入框去掉边框 → 更现代的底线式
inputDecorationTheme: const InputDecorationTheme(
border: UnderlineInputBorder(),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFFFF6B35), width: 2),
),
),
// 5. 全局字体
textTheme: const TextTheme(
headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.w800),
bodyLarge: TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
),
),
)效果
同一套 Material 组件,通过 ThemeData 深度定制后可以呈现出与默认风格完全不同的视觉效果。这是成本最低的路线。
路线二:使用 CupertinoApp(iOS 风格)
Flutter 内置了 Cupertino 组件库,实现了 iOS 风格的 UI。如果你喜欢 iOS 风格,这是最简单的选择。
基本用法
// 使用 CupertinoApp 替代 MaterialApp
CupertinoApp(
theme: const CupertinoThemeData(
primaryColor: Color(0xFFFF6B35), // 主色(影响按钮、开关等)
brightness: Brightness.light,
scaffoldBackgroundColor: Color(0xFFF2F2F7), // iOS 系统灰背景
),
home: const HomePage(),
)常用 Cupertino 组件对照
| Material 组件 | Cupertino 替代 | 说明 |
|---|---|---|
Scaffold | CupertinoPageScaffold | 页面脚手架 |
AppBar | CupertinoNavigationBar | 导航栏 |
ElevatedButton | CupertinoButton | 按钮 |
Switch | CupertinoSwitch | 开关 |
Slider | CupertinoSlider | 滑块 |
TextField | CupertinoTextField | 输入框 |
AlertDialog | CupertinoAlertDialog | 对话框 |
BottomNavigationBar | CupertinoTabBar | 底部标签栏 |
ActivityIndicator | CupertinoActivityIndicator | 加载指示器 |
示例页面
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(
middle: Text('我的应用'),
),
child: SafeArea(
child: Center(
child: CupertinoButton.filled(
onPressed: () {},
child: const Text('iOS 风格按钮'),
),
),
),
);
}
}限制
Cupertino 组件库没有 Material 那么丰富。对于缺少的组件(如 Card、Chip),你需要自己用基础组件组合,或者混用少量 Material 组件。
混用 Material 和 Cupertino
你可以在 CupertinoApp 中使用部分 Material 组件,只需在需要的地方包裹 Material:
// 在 CupertinoApp 中使用 Material 的 Card
Material(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('混合使用'),
),
),
)路线三:完全自建设计体系
这是最灵活的路线——不依赖任何设计框架,用 Flutter 的基础组件组合出完全属于自己的风格。
第一步:定义设计令牌(Design Token)
设计令牌是设计体系的"原子",所有视觉属性从这里取值:
/// 应用设计令牌 —— 所有视觉属性的唯一定义源
class AppDesign {
// ---- 颜色 ----
static const Color primary = Color(0xFFFF6B35);
static const Color secondary = Color(0xFF2EC4B6);
static const Color background = Color(0xFFFAFAFA);
static const Color surface = Color(0xFFFFFFFF);
static const Color textPrimary = Color(0xFF1A1A2E);
static const Color textSecondary = Color(0xFF6B7280);
static const Color error = Color(0xFFE63946);
static const Color divider = Color(0xFFE5E7EB);
// ---- 间距 ----
static const double spacing4 = 4;
static const double spacing8 = 8;
static const double spacing12 = 12;
static const double spacing16 = 16;
static const double spacing24 = 24;
static const double spacing32 = 32;
static const double spacing48 = 48;
// ---- 圆角 ----
static const double radiusSmall = 6;
static const double radiusMedium = 12;
static const double radiusLarge = 20;
static const double radiusFull = 9999; // 胶囊形
// ---- 字号 ----
static const double fontSizeSmall = 12;
static const double fontSizeBody = 14;
static const double fontSizeTitle = 18;
static const double fontSizeHeadline = 24;
static const double fontSizeDisplay = 32;
// ---- 阴影 ----
static List<BoxShadow> shadowSmall = [
const BoxShadow(
color: Color(0x0A000000),
blurRadius: 4,
offset: Offset(0, 2),
),
];
static List<BoxShadow> shadowMedium = [
const BoxShadow(
color: Color(0x1A000000),
blurRadius: 8,
offset: Offset(0, 4),
),
];
// ---- 动画时长 ----
static const Duration animationFast = Duration(milliseconds: 150);
static const Duration animationNormal = Duration(milliseconds: 300);
}第二步:基于令牌封装基础组件
用 Container、GestureDetector、DecoratedBox 等基础组件,组合出自有风格的组件:
自定义按钮
class AppButton extends StatelessWidget {
const AppButton({
super.key,
required this.onPressed,
required this.child,
this.variant = AppButtonVariant.filled,
});
final VoidCallback onPressed;
final Widget child;
final AppButtonVariant variant;
@override
Widget build(BuildContext context) {
final isFilled = variant == AppButtonVariant.filled;
final isOutlined = variant == AppButtonVariant.outlined;
return GestureDetector(
onTap: onPressed,
child: AnimatedContainer(
duration: AppDesign.animationNormal,
padding: const EdgeInsets.symmetric(
horizontal: AppDesign.spacing24,
vertical: AppDesign.spacing12,
),
decoration: BoxDecoration(
color: isFilled ? AppDesign.primary : Colors.transparent,
border: isOutlined
? Border.all(color: AppDesign.primary, width: 1.5)
: null,
borderRadius: BorderRadius.circular(AppDesign.radiusMedium),
),
child: DefaultTextStyle(
style: TextStyle(
color: isFilled ? Colors.white : AppDesign.primary,
fontSize: AppDesign.fontSizeBody,
fontWeight: FontWeight.w600,
),
child: child,
),
),
);
}
}
enum AppButtonVariant { filled, outlined, text }自定义卡片
class AppCard extends StatelessWidget {
const AppCard({super.key, required this.child, this.padding});
final Widget child;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppDesign.surface,
borderRadius: BorderRadius.circular(AppDesign.radiusLarge),
boxShadow: AppDesign.shadowSmall,
),
child: Padding(
padding: padding ??
const EdgeInsets.all(AppDesign.spacing16),
child: child,
),
);
}
}自定义输入框
class AppTextField extends StatelessWidget {
const AppTextField({
super.key,
this.hint,
this.controller,
this.obscureText = false,
this.onChanged,
});
final String? hint;
final TextEditingController? controller;
final bool obscureText;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppDesign.background,
borderRadius: BorderRadius.circular(AppDesign.radiusMedium),
border: Border.all(color: AppDesign.divider),
),
padding: const EdgeInsets.symmetric(
horizontal: AppDesign.spacing16,
vertical: AppDesign.spacing12,
),
child: TextField(
controller: controller,
obscureText: obscureText,
onChanged: onChanged,
style: const TextStyle(
fontSize: AppDesign.fontSizeBody,
color: AppDesign.textPrimary,
),
decoration: InputDecoration.collapsed(
hintText: hint,
hintStyle: const TextStyle(color: AppDesign.textSecondary),
),
),
);
}
}第三步:搭建页面框架
不使用 Scaffold,用基础组件组合自己的页面结构:
class AppScaffold extends StatelessWidget {
const AppScaffold({
super.key,
this.title,
required this.body,
this.bottomNav,
});
final String? title;
final Widget body;
final Widget? bottomNav;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: AppDesign.background,
child: SafeArea(
child: Column(
children: [
// 自定义导航栏
if (title != null)
Container(
height: 56,
padding: const EdgeInsets.symmetric(
horizontal: AppDesign.spacing16,
),
alignment: Alignment.centerLeft,
child: Text(
title!,
style: const TextStyle(
fontSize: AppDesign.fontSizeHeadline,
fontWeight: FontWeight.w700,
color: AppDesign.textPrimary,
),
),
),
// 内容区
Expanded(child: body),
// 底部导航
if (bottomNav != null) bottomNav!,
],
),
),
);
}
}第四步:组合页面
使用你自己的组件搭建完整页面——没有任何 Material 痕迹:
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) {
return AppScaffold(
title: '个人中心',
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppDesign.spacing16),
child: Column(
children: [
// 头像卡片
AppCard(
child: Row(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppDesign.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(AppDesign.radiusFull),
),
child: const Icon(Icons.person, color: AppDesign.primary),
),
const SizedBox(width: AppDesign.spacing16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Flutter 开发者',
style: TextStyle(
fontSize: AppDesign.fontSizeTitle,
fontWeight: FontWeight.w600,
color: AppDesign.textPrimary,
),
),
const SizedBox(height: AppDesign.spacing4),
Text(
'查看个人资料',
style: TextStyle(
fontSize: AppDesign.fontSizeSmall,
color: AppDesign.textSecondary,
),
),
],
),
],
),
),
const SizedBox(height: AppDesign.spacing16),
// 设置列表
AppCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_SettingItem(icon: Icons.settings, label: '设置'),
_divider(),
_SettingItem(icon: Icons.help, label: '帮助与反馈'),
_divider(),
_SettingItem(icon: Icons.info, label: '关于'),
],
),
),
const SizedBox(height: AppDesign.spacing32),
// 退出按钮
AppButton(
variant: AppButtonVariant.outlined,
onPressed: () {},
child: const Text('退出登录'),
),
],
),
),
);
}
Widget _divider() => const Padding(
padding: EdgeInsets.symmetric(horizontal: AppDesign.spacing16),
child: Divider(color: AppDesign.divider, height: 1),
);
}
class _SettingItem extends StatelessWidget {
const _SettingItem({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(AppDesign.spacing16),
child: Row(
children: [
Icon(icon, size: 20, color: AppDesign.textSecondary),
const SizedBox(width: AppDesign.spacing12),
Text(label, style: const TextStyle(
fontSize: AppDesign.fontSizeBody,
color: AppDesign.textPrimary,
)),
const Spacer(),
const Icon(Icons.chevron_right, size: 18, color: AppDesign.textSecondary),
],
),
);
}
}支持深色模式
自建设计体系也需要支持深色模式。最简单的方式是为 AppDesign 增加深色变体:
class AppDesign {
// 根据亮度返回不同的颜色
static Color primaryOf(BuildContext context) {
return isDark(context) ? const Color(0xFFFF8C5A) : const Color(0xFFFF6B35);
}
static Color backgroundOf(BuildContext context) {
return isDark(context) ? const Color(0xFF121212) : const Color(0xFFFAFAFA);
}
static Color surfaceOf(BuildContext context) {
return isDark(context) ? const Color(0xFF1E1E1E) : const Color(0xFFFFFFFF);
}
static Color textPrimaryOf(BuildContext context) {
return isDark(context) ? const Color(0xFFE5E5E5) : const Color(0xFF1A1A2E);
}
static bool isDark(BuildContext context) {
return MediaQuery.of(context).platformBrightness == Brightness.dark;
}
}在组件中使用:
Container(
color: AppDesign.backgroundOf(context),
child: Text(
'Hello',
style: TextStyle(color: AppDesign.textPrimaryOf(context)),
),
)更优雅的方案:ThemeExtension
使用 ThemeExtension(详见 ThemeData 主题)可以把自定义颜色注册到主题中,然后通过 Theme.of(context).extension<AppColors>() 获取。这种方式更规范,也支持 copyWith 和 lerp。
进阶:用 ThemeExtension 管理设计令牌
如果你想在自建设计体系的同时利用 Flutter 的主题机制,可以用 ThemeExtension 把设计令牌注册到 ThemeData 中:
@immutable
class AppTokens extends ThemeExtension<AppTokens> {
const AppTokens({
required this.brandPrimary,
required this.brandSecondary,
required this.bgPage,
required this.bgCard,
required this.textDefault,
required this.textMuted,
required this.radiusCard,
required this.radiusButton,
required this.spaceUnit,
});
final Color brandPrimary;
final Color brandSecondary;
final Color bgPage;
final Color bgCard;
final Color textDefault;
final Color textMuted;
final double radiusCard;
final double radiusButton;
final double spaceUnit;
@override
AppTokens copyWith({...}) => AppTokens(...); // 同上模式
@override
AppTokens lerp(covariant AppTokens? other, double t) {
if (other == null) return this;
return AppTokens(
brandPrimary: Color.lerp(brandPrimary, other.brandPrimary, t)!,
brandSecondary: Color.lerp(brandSecondary, other.brandSecondary, t)!,
bgPage: Color.lerp(bgPage, other.bgPage, t)!,
bgCard: Color.lerp(bgCard, other.bgCard, t)!,
textDefault: Color.lerp(textDefault, other.textDefault, t)!,
textMuted: Color.lerp(textMuted, other.textMuted, t)!,
radiusCard: lerpDouble(radiusCard, other.radiusCard, t)!,
radiusButton: lerpDouble(radiusButton, other.radiusButton, t)!,
spaceUnit: lerpDouble(spaceUnit, other.spaceUnit, t)!,
);
}
}注册到主题:
MaterialApp(
theme: ThemeData(
useMaterial3: true,
extensions: const [
AppTokens(
brandPrimary: Color(0xFFFF6B35),
brandSecondary: Color(0xFF2EC4B6),
bgPage: Color(0xFFFAFAFA),
bgCard: Color(0xFFFFFFFF),
textDefault: Color(0xFF1A1A2E),
textMuted: Color(0xFF6B7280),
radiusCard: 20,
radiusButton: 12,
spaceUnit: 4,
),
],
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
extensions: const [
AppTokens(
brandPrimary: Color(0xFFFF8C5A),
brandSecondary: Color(0xFF4DD8C8),
bgPage: Color(0xFF121212),
bgCard: Color(0xFF1E1E1E),
textDefault: Color(0xFFE5E5E5),
textMuted: Color(0xFF9CA3AF),
radiusCard: 20,
radiusButton: 12,
spaceUnit: 4,
),
],
),
)在组件中获取:
final tokens = Theme.of(context).extension<AppTokens>()!;
Container(
decoration: BoxDecoration(
color: tokens.bgCard,
borderRadius: BorderRadius.circular(tokens.radiusCard),
),
child: Text('Hello', style: TextStyle(color: tokens.textDefault)),
)第三方设计体系
如果你不想从零搭建,也有一些现成的第三方设计体系可选:
| 名称 | 风格 | 地址 |
|---|---|---|
| Cupertino | iOS 原生风格 | Flutter 内置 |
| MacosUI | macOS 原生风格 | pub.dev/packages/macos_ui |
| FluentUI | Windows Fluent 风格 | pub.dev/packages/fluent_ui |
| ShadcnUI | 现代极简风格 | pub.dev/packages/shadcn_ui |
| Mix | 样式与结构分离 | pub.dev/packages/mix |
选择建议
- 想 iOS 风格 → 用内置 Cupertino
- 想 跨桌面原生感 → macos_ui / fluent_ui
- 想 现代 Web 感 → shadcn_ui
- 想 高度灵活的样式系统 → Mix
- 想 完全独特 → 自建(路线三)
决策流程图
不喜欢 Material 风格?
│
├─ 只是不喜欢默认配色/圆角
│ └─ ✅ 路线一:深度定制 ThemeData
│
├─ 想要 iOS 风格
│ └─ ✅ 路线二:CupertinoApp
│
├─ 想要桌面原生风格
│ └─ ✅ 第三方:macos_ui / fluent_ui
│
└─ 想要完全独特的品牌风格
└─ ✅ 路线三:自建设计体系速查表
| 需求 | 方案 |
|---|---|
| 改 Material 配色 | ThemeData(colorScheme: ColorScheme(...)) |
| 改 Material 圆角 | ThemeData(cardTheme:, elevatedButtonTheme:, ...) |
| iOS 风格 | CupertinoApp + CupertinoPageScaffold |
| 完全自建 | AppDesign 令牌类 + 自定义组件 + 基础 Widget |
| 设计令牌管理 | ThemeExtension<AppTokens> |
| 深色模式 | 亮/暗两套 ThemeExtension + theme/darkTheme |
| 桌面原生风格 | macos_ui / fluent_ui |
