신규앱 카드 애니메이션 개발을 위한 애니메이션 예제 분석
설명이빈약합니다. 하단에 소스코드 있어용
lib
├── contact.dart
├── main.dart
├── model
│ └── contact.dart
├── my_app.dart
└── ui
├── contact_card.dart
├── home_page.dart
└── perspective_list_view.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'my_app.dart';
void main() {
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(statusBarColor: Colors.transparent));
runApp(const MyApp());
}
import 'package:animation/ui/home_page.dart';
import 'package:flutter/material.dart';
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xff23202a),
appBarTheme: AppBarTheme(
centerTitle: true, color: Colors.deepPurple[400], iconTheme: const IconThemeData(color: Colors.white70)),
textTheme: const TextTheme(bodyText2: TextStyle(color: Color(0xff303030))),
iconTheme: const IconThemeData(color: Colors.grey),
),
home: const ContactListPage(),
);
}
}
class Contact {
final String name;
final String role;
final String address;
final String phone;
final String email;
final String website;
const Contact(this.name, this.role, this.address, this.phone, this.email, this.website);
static const contacts = [
Contact('김영진', '개발자', '서울', '010-1111-1111', 'aaa@aaa.aa', 'https://www.aa.com'),
Contact('김영진', '개발자', '서울', '010-1111-1111', 'aaa@aaa.aa', 'https://www.aa.com'),
Contact('김영진', '개발자', '서울', '010-1111-1111', 'aaa@aaa.aa', 'https://www.aa.com'),
Contact('김영진', '개발자', '서울', '010-1111-1111', 'aaa@aaa.aa', 'https://www.aa.com'),
Contact('김영진', '개발자', '서울', '010-1111-1111', 'aaa@aaa.aa', 'https://www.aa.com'),
Contact('김영진', '개발자', '서울', '010-1111-1111', 'aaa@aaa.aa', 'https://www.aa.com'),
Contact('김영진', '개발자', '서울', '010-1111-1111', 'aaa@aaa.aa', 'https://www.aa.com'),
...
];
}
import 'package:animation/ui/contact_card.dart';
import 'package:animation/ui/perspective_list_view.dart';
import 'package:flutter/material.dart';
import '../model/contact.dart';
class ContactListPage extends StatelessWidget {
const ContactListPage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('템플릿 겔러리'),
leading: IconButton(onPressed: () {}, icon: const Icon(Icons.menu)),
actions: [
IconButton(onPressed: () {}, icon: const Icon(Icons.search)),
],
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(20))),
),
body: PerspectiveListView(
// 보여지는 카드 개수
visualizedItems: 10,
// 카드 높이
itemExtent: MediaQuery.of(context).size.height * 0.33,
// 시작 인덱스
initialIndex: 7,
// 쉐도우
backItemsShadowColor: Theme.of(context).scaffoldBackgroundColor,
// 패딩
padding: const EdgeInsets.all(10),
// 카드 리스트
children: List.generate(Contact.contacts.length, (index) {
final contact = Contact.contacts[index];
final borderColor = Colors.accents[index % Colors.accents.length];
return ContactCard(borderColor: borderColor, contact: contact);
}),
),
);
}
}
import 'package:animation/model/contact.dart';
import 'package:flutter/material.dart';
class ContactCard extends StatelessWidget {
const ContactCard({Key? key, required this.borderColor, required this.contact}) : super(key: key);
final Color borderColor;
final Contact contact;
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Card tab
Align(
heightFactor: .9,
alignment: Alignment.centerLeft,
child: Container(
height: 30,
width: 70,
decoration:
BoxDecoration(color: borderColor, borderRadius: const BorderRadius.vertical(top: Radius.circular(10))),
child: const Icon(
Icons.add,
color: Colors.white,
),
),
),
// Card Body
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: borderColor,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20), topRight: Radius.circular(20))),
child: Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name and Role
Row(
children: [
Icon(Icons.person_outline, size: 40),
const SizedBox(width: 10),
Flexible(child: Text.rich(TextSpan(text: '\n${contact.role}', style: TextStyle())))
],
)
],
),
),
),
)
],
);
}
}
class PerspectiveListView extends StatefulWidget {
const PerspectiveListView({
Key? key,
required this.children,
required this.itemExtent,
required this.visualizedItems,
this.initialIndex = 0,
this.padding = const EdgeInsets.all(0),
this.onTapFrontItem,
this.onChangeItem,
this.backItemsShadowColor = Colors.black,
}) : super(key: key);
final List<Widget> children;
final double itemExtent;
final int visualizedItems;
final int initialIndex;
final EdgeInsetsGeometry padding;
final ValueChanged<int>? onTapFrontItem;
final ValueChanged<int>? onChangeItem;
final Color backItemsShadowColor;
State<PerspectiveListView> createState() => _PerspectiveListViewState();
}
class _PerspectiveListViewState extends State<PerspectiveListView> {
late PageController _pageController;
late double _pagePercent;
late int _currentIndex;
void initState() {
// 시작 인덱스 등록
_pageController = PageController(initialPage: widget.initialIndex, viewportFraction: 1 / widget.visualizedItems);
// 현재 인덱스 등록
_currentIndex = widget.initialIndex;
// 뭐하는 파라미터인지 아직 모르겠음
_pagePercent = 0.0;
_pageController.addListener(_pageListener);
super.initState();
}
void dispose() {
_pageController.removeListener(_pageListener);
_pageController.dispose();
super.dispose();
}
void _pageListener() {
// 현재 페이지는 페이지컨트롤러의 페이지값을 내림함
// 페이지 컨트롤러는 0.0000000~xx.000000000의 실수임
// 따라서 내림하면 첫페이지는 0 두번째페이지는 1 이렇게 감
// pagePercent는 페이지값에서 현재 인덱스를 뺀값의 절댓값
// 페이지가 얼마나 이동했는지를 알려주는 값이네
_currentIndex = _pageController.page!.floor();
_pagePercent = (_pageController.page! - _currentIndex).abs();
setState(() {});
}
LayoutBuilder로 최대높이로 빌드
Stack을 활용하여 shadow 효과 주기
build(BuildContext context) {
return LayoutBuilder(builder: (context, constrains) {
final height = constrains.maxHeight;
return Stack(
children: [
// 예정된 페이지
Padding(
padding: widget.padding,
child: _PerspectiveItems(
// 화면세로크기의 0.33
heightItem: widget.itemExtent,
// 현재 인덱스
currentIndex: _currentIndex,
// 자식들
children: widget.children,
// 생성되는 아이템
generateItems: widget.visualizedItems - 1,
pagePercent: _pagePercent,
),
),
// 뒤에있는 아이템 그림자
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [
widget.backItemsShadowColor.withOpacity(0.8),
widget.backItemsShadowColor.withOpacity(0)
])),
)),
// 비어있는 페이지
PageView.builder(
scrollDirection: Axis.vertical,
controller: _pageController,
physics: const BouncingScrollPhysics(),
onPageChanged: (value) {
if (widget.onChangeItem != null) {
widget.onChangeItem!(value);
}
},
itemBuilder: (context, index) {
return const SizedBox();
},
),
// 아이템 탭하는 지역
Positioned.fill(
top: height - widget.itemExtent,
child: GestureDetector(
onTap: () {
if (widget.onTapFrontItem != null) {
widget.onTapFrontItem!(_currentIndex);
}
},
),
)
],
);
});
}
}
Widget
build(BuildContext context) {
// 높이 최대로 하고
return LayoutBuilder(builder: (context, constrains) {
final height = constrains.maxHeight;
// expand 주고
return Stack(
fit: StackFit.expand,
children: List.generate(generateItems, (index) {
// 거꾸로된 인덱스 = 보여지는 아이템길이 - 처음꺼 - 나중꺼 - 현재인덱스
final invertedIndex = (generateItems - 2) - index;
// 인덱스 앞에꺼
final indexPlus = index + 1;
// 애니메이션 위치
final positionPercent = indexPlus / generateItems;
// 끝나는 애니메이션 위치
final endPositionPercent = index / generateItems;
// 현재 인덱스가 0 이면
return (currentIndex > invertedIndex)
? _TransformedItem(
child: children[currentIndex - (invertedIndex + 1)],
heightItem: heightItem,
factorChange: pagePercent,
scale: lerpDouble(0.5, 1.0, positionPercent)!,
endScale: lerpDouble(0.5, 1.0, endPositionPercent)!,
translateY: (height - heightItem) * positionPercent,
endTranslateY: (height - heightItem) * endPositionPercent,
)
: const SizedBox();
})
// 아래 아이템 숨기기(그냥 빡 뜨는게 아니라 아래에서 자연스럽게 올라옴
..add((currentIndex < children.length - 1)
? _TransformedItem(
child: children[currentIndex + 1],
heightItem: heightItem,
factorChange: pagePercent,
translateY: height + 20,
endTranslateY: height - heightItem,
)
: const SizedBox())
// 상단 카드 고정하기
..insert(
0,
currentIndex > generateItems - 1
? _TransformedItem(
child: children[currentIndex - generateItems],
heightItem: heightItem,
factorChange: 1.0,
endScale: 0.5,
)
: const SizedBox()),
);
});
}
Widget
class _TransformedItem extends StatelessWidget {
const _TransformedItem({
Key? key,
required this.child,
required this.heightItem,
required this.factorChange,
this.scale = 1.0,
this.endScale = 1.0,
this.translateY = 0.0,
this.endTranslateY = 0.0,
}) : super(key: key);
final Widget child;
final double heightItem;
final double factorChange;
final double scale;
final double endScale;
final double translateY;
final double endTranslateY;
Widget build(BuildContext context) {
return Transform(
alignment: Alignment.topCenter,
transform: Matrix4.identity()
..scale(lerpDouble(scale, endScale, factorChange))
..translate(0.0, lerpDouble(translateY, endTranslateY, factorChange)!, 0.0),
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: heightItem,
width: double.infinity,
child: child,
),
),
);
}
}
레이아웃 빌더 안에
스택 넣고
1. pageController.page값을 리슨해서 카드 스택 구성하고
2. pageView에 비어있는 위젯 넣어서 페이지 리슨만 시킴
내가 만든 슬라이더 위젯!!!
핵심 소스만 첨부합니다!
import 'package:flutter/material.dart';
import 'package:withapp_did/certificate/model/cert_card.dart';
class CertCardListView extends StatefulWidget {
const CertCardListView({
Key? key,
}) : super(key: key);
State<CertCardListView> createState() => _CertCardListViewState();
}
class _CertCardListViewState extends State<CertCardListView> {
late final PageController pageController;
int currentPage = 0;
late double percent = 0.0;
String status = 'stacked';
void initState() {
pageController = PageController(viewportFraction: 1 / 3, initialPage: 0);
pageController.addListener(pageListener);
super.initState();
}
void dispose() {
pageController.removeListener(pageListener);
pageController.dispose();
super.dispose();
}
void pageListener() {
currentPage = pageController.page!.floor();
percent = pageController.page! - currentPage.toDouble();
setState(() {});
}
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 36),
child: LayoutBuilder(builder: (context, constrains) {
final cardWidth = constrains.maxWidth * 0.66;
final cardHeight = constrains.maxHeight * 0.92;
final cardTranslateX = (constrains.maxWidth - cardWidth) / 3;
final cardTranslateY = (constrains.maxHeight - cardHeight) / 2;
return Stack(
fit: StackFit.expand,
children: [
Stack(
fit: StackFit.loose,
children: [
currentPage + percent + 3 < CertCard.certCards.length
? Container(
margin: EdgeInsets.only(left: 24 + cardTranslateX * 2, top: cardTranslateY * 2),
width: cardWidth,
height: cardHeight,
decoration: BoxDecoration(
gradient:
const LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
]),
border: Border.all(color: const Color.fromRGBO(0, 155, 137, 1), width: 2),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.only(left: 17, top: 11),
height: 140,
width: cardWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
Text(
'팀 인증서',
style: TextStyle(
fontFamily: '210_MGothic',
fontWeight: FontWeight.bold,
fontSize: 16,
letterSpacing: 0.8,
color: Colors.black54),
),
Text(
'프로젝트 위드',
style: TextStyle(
fontFamily: '210_MGothic',
fontWeight: FontWeight.w700,
fontSize: 24,
letterSpacing: -1.2,
height: 1.5,
color: Colors.black),
),
],
),
),
Expanded(
child: Stack(
fit: StackFit.loose,
alignment: Alignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 36),
decoration: const BoxDecoration(
gradient: SweepGradient(colors: [
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
]),
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(20),
)),
width: cardWidth,
),
LayoutBuilder(builder: (context, constrains) {
return Container(
height: constrains.maxHeight / 2,
width: constrains.maxHeight / 2,
padding: EdgeInsets.all(constrains.maxHeight / 15),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 1.5)),
child: Container(
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: const Icon(
Icons.qr_code_rounded,
size: 30,
),
),
);
})
],
),
)
],
),
)
: const SizedBox(),
...List.generate(2, (index) {
return currentPage + index < CertCard.certCards.length - 1
? Transform.translate(
offset: Offset(cardTranslateX * (index) + cardTranslateX * (1 - percent),
cardTranslateY * (index) + cardTranslateY * (1 - percent)),
child: Container(
margin: const EdgeInsets.only(left: 24),
width: cardWidth,
height: cardHeight,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
]),
border: Border.all(color: const Color.fromRGBO(0, 155, 137, 1), width: 2),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.only(left: 17, top: 11),
height: 140,
width: cardWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'팀 인증서',
style: TextStyle(
fontFamily: '210_MGothic',
fontWeight: FontWeight.bold,
fontSize: 16,
letterSpacing: 0.8,
color: Colors.black54),
),
Text(
CertCard.certCards[currentPage + 1].name,
style: TextStyle(
fontFamily: '210_MGothic',
fontWeight: FontWeight.w700,
fontSize: 24,
letterSpacing: -1.2,
height: 1.5,
color: Colors.black),
),
],
),
),
Expanded(
child: Stack(
fit: StackFit.loose,
alignment: Alignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 36),
decoration: const BoxDecoration(
gradient: SweepGradient(colors: [
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
]),
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(20),
)),
width: cardWidth,
),
LayoutBuilder(builder: (context, constrains) {
return Container(
height: constrains.maxHeight / 2,
width: constrains.maxHeight / 2,
padding: EdgeInsets.all(constrains.maxHeight / 15),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 1.5)),
child: Container(
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: const Icon(
Icons.qr_code_rounded,
size: 30,
),
),
);
})
],
),
)
],
),
),
)
: const SizedBox();
}).reversed,
Transform.translate(
offset: Offset(percent * -300, 0),
child: Container(
margin: const EdgeInsets.only(left: 24),
width: cardWidth,
height: cardHeight,
decoration: BoxDecoration(
gradient:
const LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
]),
border: Border.all(color: const Color.fromRGBO(0, 155, 137, 1), width: 2),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.only(left: 17, top: 11),
height: 140,
width: cardWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'팀 인증서',
style: TextStyle(
fontFamily: '210_MGothic',
fontWeight: FontWeight.bold,
fontSize: 16,
letterSpacing: 0.8,
color: Colors.black54),
),
Text(
CertCard.certCards[currentPage].name,
style: TextStyle(
fontFamily: '210_MGothic',
fontWeight: FontWeight.w700,
fontSize: 24,
letterSpacing: -1.2,
height: 1.5,
color: Colors.black),
),
],
),
),
Expanded(
child: Stack(
fit: StackFit.loose,
alignment: Alignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 36),
decoration: const BoxDecoration(
gradient: SweepGradient(colors: [
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
Color.fromRGBO(0, 196, 135, 1),
Color.fromRGBO(0, 90, 62, 1),
]),
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(20),
)),
width: cardWidth,
),
LayoutBuilder(builder: (context, constrains) {
return Container(
height: constrains.maxHeight / 2,
width: constrains.maxHeight / 2,
padding: EdgeInsets.all(constrains.maxHeight / 15),
decoration: BoxDecoration(
shape: BoxShape.circle, border: Border.all(color: Colors.black, width: 1.5)),
child: Container(
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: const Icon(
Icons.qr_code_rounded,
size: 30,
),
),
);
})
],
),
)
],
),
),
),
],
),
PageView(
controller: pageController,
physics: const BouncingScrollPhysics(),
children: List.generate(CertCard.certCards.length, (index) => const SizedBox())),
],
);
}),
),
);
}
}