Flutter自定义按钮:如何实现订阅计划按钮的指定样式?
应用内购买计划按钮实现方案
问题说明
需要实现3个应用内购买计划的选择按钮(单选逻辑),目标样式要求右上角带图标角标,当前代码实现效果不符合预期,尤其是角标样式无法达标。
当前尝试代码
SizedBox( width: 250.0, height: 60.0, child: Stack( children: [ const Positioned( top: 0, right: 0, child: Text( "%", style: TextStyle( color: Colors.white, backgroundColor: Colors.amberAccent, fontWeight: FontWeight.bold, ), ), ), OutlinedButton( style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 5), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10.0)), side: const BorderSide( color: mainColor, ), ), onPressed: () {}, child: Container( padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 5), child: Column( children: [ const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "1 year", style: TextStyle( color: Colors.white, ), ), Text( "EGP 150.00", style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( "Save 60%", style: TextStyle( color: Colors.amberAccent, fontWeight: FontWeight.bold, ), ), Text( "EGP 12.33 / month", style: TextStyle( color: Colors.grey[350], ), ), ], ), ], ), )) ], ), ),
优化方案
1. 实现右上角带图标的角标样式
通过Stack+Positioned配合自定义裁剪器,实现斜角标签效果并加入图标:
Positioned( top: 0, right: 0, child: ClipPath( clipper: TriangleClipper(), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), color: Colors.amberAccent, child: Row( mainAxisSize: MainAxisSize.min, children: const [ Icon(Icons.local_offer, color: Colors.white, size: 14), SizedBox(width: 4), Text( "60% OFF", style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ], ), ), ), )
自定义裁剪器代码:
class TriangleClipper extends CustomClipper<Path> { @override Path getClip(Size size) { final path = Path(); path.moveTo(size.width, 0); path.lineTo(size.width, size.height); path.lineTo(0, size.height); path.close(); return path; } @override bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false; }
2. 实现单选按钮逻辑
用StatefulWidget管理选中状态,动态切换按钮的边框和背景样式:
// 定义选中状态变量 String? selectedPlan = "1year"; // 按钮样式根据选中状态切换 OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), side: BorderSide( color: selectedPlan == "1year" ? Colors.amberAccent : Colors.grey, width: 2, ), backgroundColor: selectedPlan == "1year" ? Colors.amberAccent.withOpacity(0.1) : Colors.transparent, )
3. 优化内部布局
调整按钮内文字的间距、字号和对齐方式,避免内容拥挤:
child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( "1 year", style: TextStyle(color: Colors.white, fontSize: 14), ), Text( "EGP 150.00", style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, ), ), ], ), const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( "Save 60%", style: TextStyle( color: Colors.amberAccent, fontWeight: FontWeight.bold, fontSize: 12, ), ), Text( "EGP 12.33 / month", style: TextStyle( color: Colors.grey[400], fontSize: 12, ), ), ], ), ], )
完整示例代码
import 'package:flutter/material.dart'; class PurchasePlans extends StatefulWidget { const PurchasePlans({super.key}); @override State<PurchasePlans> createState() => _PurchasePlansState(); } class _PurchasePlansState extends State<PurchasePlans> { String? selectedPlan = "1year"; @override Widget build(BuildContext context) { return Column( children: [ _buildPlanButton( planId: "1month", title: "1 month", price: "EGP 37.99", monthlyPrice: "EGP 37.99 / month", hasTag: false, ), const SizedBox(height: 12), _buildPlanButton( planId: "6months", title: "6 months", price: "EGP 149.99", monthlyPrice: "EGP 24.99 / month", saveText: "Save 34%", hasTag: true, ), const SizedBox(height: 12), _buildPlanButton( planId: "1year", title: "1 year", price: "EGP 150.00", monthlyPrice: "EGP 12.33 / month", saveText: "Save 60%", hasTag: true, ), ], ); } Widget _buildPlanButton({ required String planId, required String title, required String price, required String monthlyPrice, String? saveText, bool hasTag = false, }) { return SizedBox( width: 280, height: 70, child: Stack( children: [ if (hasTag && saveText != null) Positioned( top: 0, right: 0, child: ClipPath( clipper: TriangleClipper(), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), color: Colors.amberAccent, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.local_offer, color: Colors.white, size: 14), const SizedBox(width: 4), Text( saveText.toUpperCase(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, ), ), ], ), ), ), ), OutlinedButton( style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), side: BorderSide( color: selectedPlan == planId ? Colors.amberAccent : Colors.grey.shade700, width: 2, ), backgroundColor: selectedPlan == planId ? Colors.amberAccent.withOpacity(0.1) : Colors.transparent, ), onPressed: () { setState(() { selectedPlan = planId; }); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( title, style: const TextStyle(color: Colors.white, fontSize: 14), ), Text( price, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, ), ), ], ), const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (saveText != null) Text( saveText, style: const TextStyle( color: Colors.amberAccent, fontWeight: FontWeight.bold, fontSize: 12, ), ), Text( monthlyPrice, style: TextStyle( color: Colors.grey[400], fontSize: 12, ), ), ], ), ], ), ), ], ), ); } } class TriangleClipper extends CustomClipper<Path> { @override Path getClip(Size size) { final path = Path(); path.moveTo(size.width, 0); path.lineTo(size.width, size.height); path.lineTo(0, size.height); path.close(); return path; } @override bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false; }
内容的提问来源于stack exchange,提问作者Emad Younan
相关产品推荐
相关产品推荐

