일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- MVVM
- 자바스크립트
- .NET
- 깃허브
- spring boot
- Flutter
- Maui
- 마우이
- Firebase
- GitHub
- Binding
- JavaScript
- 애니메이션
- 함수
- AnimationController
- MSSQL
- 파이어베이스
- React JS
- typescript
- db
- Animation
- 닷넷
- HTML
- listview
- MS-SQL
- 바인딩
- page
- 오류
- 플러터
- 리엑트
Archives
- Today
- Total
개발노트
65. [Flutter] 카드 스윕(Swiping), 드래그(Drag) 기능 만들어보기(무한 스윕 트릭 사용) with onHorizontalDragUpdate, onHorizontalDragEnd 본문
앱 개발/Flutter
65. [Flutter] 카드 스윕(Swiping), 드래그(Drag) 기능 만들어보기(무한 스윕 트릭 사용) with onHorizontalDragUpdate, onHorizontalDragEnd
mroh1226 2024. 2. 6. 17:16반응형
애니메이션 조합으로 아래와 같이 카드 스윕, 드래그 기능을 만들어보겠습니다.
(소스 작성 숙달을 위해 소스 캡슐화 대신 하나하나 입력해보았습니다.)
준비사항.
1. 프로젝트에 assets 폴더를 생성하고 그안에 카드 이미지로 사용될 jpg 파일을 넣습니다.
*index에 따라 불러올 수 있도록 이름은 1,2,3,4와 같은 숫자로 해줍니다.
2. 프로젝트에서 assets폴더를 인식할 수 있게 끔 pubspec.yaml에 flutter: 속성에 - asswets/ 를 추가해줍니다.
예시 소스(전체)
import 'dart:math';
import 'package:flutter/material.dart';
class SwipingCardsScreen extends StatefulWidget {
static String routeName = "swingcard";
static String routeURL = "swingcard";
const SwipingCardsScreen({super.key});
@override
State<SwipingCardsScreen> createState() => _SwipingCardsScreenState();
}
class _SwipingCardsScreenState extends State<SwipingCardsScreen>
with SingleTickerProviderStateMixin {
late final size = MediaQuery.of(context).size;
late final AnimationController _animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
//_animationController의 최대값이 1 최소값이 0 이기 때문에 움직임이 제한적임
//따라서 lowBound, upperBound를 MideaQuery를 곱해서 정해줌
lowerBound: (size.width + 100) * -1,
upperBound: (size.width + 100),
value: 0.0 //value를 0으로 설정하여 초기값을 0으로 만들어 위치를 가운데로 오게함
);
double posX = 0;
int index = 1;
void _onHorizontalDragUpdate(DragUpdateDetails details) {
//드래그할 때 x값을 animationcontroller.value에 넣어줌(드래그 위치에 따라 x 값이 업데이트됨)
_animationController.value += details.delta.dx;
}
void _onHorizontalDragEnd(DragEndDetails details) {
//print(_animationController.value.abs());
final bound = size.width - 200;
//절대값이 bound 값보다 클때 _animationController.value를 size.width + 100 을 만들어 사라지게 만들어줌
if (_animationController.value.abs() >= bound) {
//음수일때는 왼쪽으로 넘어가게 함
if (_animationController.value.isNegative) {
//.whenComplete()를 사용하여 카드를 버리는 애니메이션이 완료되었을 때 value를 다시 0으로 만들어 되돌림
_animationController
.animateTo((size.width + 200) * -1)
.whenComplete(() {
_animationController.value = 0;
index = index == 13 ? 1 : index + 1;
setState(() {});
});
} else {
_animationController.animateTo(size.width + 200).whenComplete(() {
_animationController.value = 0;
index = index == 13 ? 1 : index + 1;
setState(() {});
});
}
} else {
//드래그 끝냈을 때 animationcontroller.value 값을 0으로 넣고, curve를 줘서 애니메이션에 굴곡을 줌
_animationController.animateTo(0,
curve: Curves.bounceOut, duration: const Duration(milliseconds: 500));
}
}
late final Tween<double> _rotation = Tween(
begin: -15,
end: 15,
);
late final Tween<double> _scale = Tween(begin: 0.8, end: 1.0);
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Scaffold(
appBar: AppBar(
title: const Text("Swiping Cards"),
elevation: 0,
),
body: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
//angle 계산의미: 화면 중앙에 위치하기 위함
final angle = _rotation.transform(
(_animationController.value + size.width / 2) / size.width);
final scale =
_scale.transform(_animationController.value.abs() / size.width);
return Padding(
padding: const EdgeInsets.fromLTRB(0, 30, 0, 0),
child: Column(
children: [
Stack(
children: [
Positioned(
left: 40,
child: Transform.scale(
scale: min(scale, 1.0), //min()둘중 작은 작은 수를 반환하는 함수
child: Card(
index: index == 13 ? 1 : index + 1,
))),
Align(
alignment: Alignment.topCenter,
child: GestureDetector(
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: Transform.translate(
offset: Offset(_animationController.value, 0),
child: Transform.rotate(
angle: angle * pi / 180,
child: Card(index: index)),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
GestureDetector(
onTap: () {
_animationController
.animateTo((size.width + 200) * -1)
.whenComplete(() {
_animationController.value = 0;
index = index == 13 ? 1 : index + 1;
setState(() {});
});
},
child: AnimatedScale(
scale:
_animationController.value < 0 ? scale * 1.5 : 1,
duration: const Duration(milliseconds: 50),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withOpacity(
_animationController.value > 0
? 0
: _animationController.value.abs() /
_animationController.upperBound),
border:
Border.all(width: 1, color: Colors.green)),
child: Icon(
Icons.done,
color: Colors.lightGreen.withOpacity(
_animationController.value > 0
? 1
: 1 -
_animationController.value.abs() /
_animationController.upperBound),
size: 32,
),
),
),
),
GestureDetector(
onTap: () {
_animationController
.animateTo(size.width + 200)
.whenComplete(() {
_animationController.value = 0;
index = index == 13 ? 1 : index + 1;
setState(() {});
});
},
child: AnimatedScale(
scale:
_animationController.value > 0 ? scale * 1.5 : 1,
duration: const Duration(milliseconds: 50),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red.withOpacity(
_animationController.value < 0
? 0
: _animationController.value.abs() == 0
? 0
: _animationController.value.abs() /
_animationController
.upperBound),
border:
Border.all(width: 1, color: Colors.pink)),
child: Icon(
Icons.close,
size: 32,
color: Colors.pink.withOpacity(
_animationController.value < 0
? 1
: 1 -
_animationController.value.abs() /
_animationController.upperBound),
),
),
),
)
],
),
)
],
),
);
},
),
);
}
}
class Card extends StatelessWidget {
final int index;
const Card({super.key, required this.index});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Material(
elevation: 10,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: size.width * 0.8,
height: size.height * 0.7,
child: Image.asset(
"assets/movies/$index.jpg",
fit: BoxFit.cover,
),
),
);
}
}
소스설명.
state 부분 설명
class _SwipingCardsScreenState extends State<SwipingCardsScreen>
with SingleTickerProviderStateMixin {
late final size = MediaQuery.of(context).size;
late final AnimationController _animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
//_animationController의 최대값이 1 최소값이 0 이기 때문에 움직임이 제한적임
//따라서 lowBound, upperBound를 MideaQuery를 곱해서 정해줌
lowerBound: (size.width + 100) * -1,
upperBound: (size.width + 100),
value: 0.0 //value를 0으로 설정하여 초기값을 0으로 만들어 위치를 가운데로 오게함
);
- AnimationController를 Ticker와 함께 사용하기 위해 SingleTickerProviderStateMixin을 with 문과 함께 작성해줍니다.
- Drag했을 때 최대, 최솟값을 고려하여 lowerBound와 upperBound를 입력해줍니다.
- 왼쪽 드래그를 했을 때 value는 음수이기 때문에 lowerBound에 *-1을 해준 값을 집어넣습니다.(+100은 임시로 더해준값임
- 초기 값을 0 으로 주기위해 value: 0.0 으로 설정해줍니다.
Darg 재스쳐 부분 로직 설명
GestureDetector(
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: Transform.translate(
offset: Offset(_animationController.value, 0),
child: Transform.rotate(
angle: angle * pi / 180,
child: Card(index: index)),
),
),
- onHorizontalDragUpdate: 드래그 재스쳐가 진행중일 때 _ onHorizontalDragUpdate() 메소드를 호출합니다.
- onHorizontalDragEnd: 드래그 재스쳐가 종료되었을 때 _ onHorizontalDragEnd() 메소드를 호출합니다.
- 각각 DragUpdateDetails, DragEndDetails 객체를 메소드에 전달합니다. 이로 인해 드래그되는 위치를 값(수치)으로 알 수있습니다.(details.delta.dx: 가로방향 위치 값, details.delta.dy: 세로방향 위치 값
double posX = 0;
int index = 1;
void _onHorizontalDragUpdate(DragUpdateDetails details) {
//드래그할 때 x값을 animationcontroller.value에 넣어줌(드래그 위치에 따라 x 값이 업데이트됨)
_animationController.value += details.delta.dx;
}
void _onHorizontalDragEnd(DragEndDetails details) {
//print(_animationController.value.abs());
final bound = size.width - 200;
//절대값이 bound 값보다 클때 _animationController.value를 size.width + 100 을 만들어 사라지게 만들어줌
if (_animationController.value.abs() >= bound) {
//음수일때는 왼쪽으로 넘어가게 함
if (_animationController.value.isNegative) {
//.whenComplete()를 사용하여 카드를 버리는 애니메이션이 완료되었을 때 value를 다시 0으로 만들어 되돌림
_animationController
.animateTo((size.width + 200) * -1)
.whenComplete(() {
_animationController.value = 0;
index = index == 13 ? 1 : index + 1;
setState(() {});
});
} else {
_animationController.animateTo(size.width + 200).whenComplete(() {
_animationController.value = 0;
index = index == 13 ? 1 : index + 1;
setState(() {});
});
}
} else {
//드래그 끝냈을 때 animationcontroller.value 값을 0으로 넣고, curve를 줘서 애니메이션에 굴곡을 줌
_animationController.animateTo(0,
curve: Curves.bounceOut, duration: const Duration(milliseconds: 500));
}
}
late final Tween<double> _rotation = Tween(
begin: -15,
end: 15,
);
late final Tween<double> _scale = Tween(begin: 0.8, end: 1.0);
- 절대값 함수 abs()를 사용하여 왼쪽, 오른쪽 구분없이 일정 값이되면 Swiping되는 기준을 조건문으로 작성합니다.
- .animateTo()로 지정된 값까지 애니메이션을 수행하도록 만들어줍니다.
음수라면 좌측으로, 양수라면 우측으로 회전하여 화면에 안보이게 만들어주는 조건문을 추가합니다. - .whenComplete()로 애니메이션이 완료되었을 때 _animationController.value를 0으로 주어 카드가 다시 가운데로 돌아오게 합니다.
- 또한, 이미지가1.jpg ~ 13.jpg까지 있기 때문에 13이 넘어가면 1로 다시 돌아가도록 index에 수식을 넣어줍니다.
- 회전과 스케일 변화를 애니메이션으로 주기 위해 각각 Tween을 작성해줍니다.
Card 위젯
class Card extends StatelessWidget {
final int index;
const Card({super.key, required this.index});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Material(
elevation: 10,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: size.width * 0.8,
height: size.height * 0.7,
child: Image.asset(
"assets/movies/$index.jpg",
fit: BoxFit.cover,
),
),
);
}
}
- Card 위젯은 카드를 나타내며, index를 required로 전달 받아 해당 인덱스의 카드 이미지를 로드합니다.
(이미지 파일명을 숫자로 준 이유) - 카드 크기는 MediaQuery로 받아온 size로 비율을 이용하여 width, height를 줍니다.
Scaffold Widget 부분
body: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
//angle 계산의미: 화면 중앙에 위치하기 위함
final angle = _rotation.transform(
(_animationController.value + size.width / 2) / size.width);
final scale =
_scale.transform(_animationController.value.abs() / size.width);
return Padding(
padding: const EdgeInsets.fromLTRB(0, 30, 0, 0),
child: Column(
children: [
Stack(
children: [
- 회전을 사용하기위해 angle 과 scale을 .transform()으로 선언해줍니다.
- 2개의 Card를 Stack으로 감싸 겹겹이 쌓는 형식의 구조를 만들어줍니다.
(Swip했을때 뒤에 카드가 나올 수 있게)
Swiping 되는 Card
Positioned(
left: 40,
child: Transform.scale(
scale: min(scale, 1.0), //min()둘중 작은 작은 수를 반환하는 함수
child: Card(
index: index == 13 ? 1 : index + 1,
))),
- Transform.scale() 에 min() 함수를 이용하여 scale 변수 값과 1 중에 작은 값을 scale: 속성에 반환합니다.
(작은 크기로 뒤에 있다가 앞 Card가 스윕되면 scale이 1로 커지는 형태의 애니메이션) - Card의 index가 13이 넘지않으며, index + 1 을 통해 다음 이미지를 가져옵니다.
Swiping 뒤에 있는 Card
Align(
alignment: Alignment.topCenter,
child: GestureDetector(
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: Transform.translate(
offset: Offset(_animationController.value, 0),
child: Transform.rotate(
angle: angle * pi / 180,
child: Card(index: index)),
),
),
),
- Transform.translate() 를 이용하여 이동 위치값을 Offset(x축: _animationController.value, y축:0) 에 매핑하여 x축 이동값을 Drag되는 delta.dx 값에 따라 이동하게 만들어줍니다.
- Transform.rotate()에 angle을 매핑하여 angle값에 따른 회전을 줍니다.
- Drag 할때는 _onHorizontalDragUpdate 메소드가 호출됩니다.
- Drag가 종료되었을 때는 _onHorizontalDragEnd가 호출됩니다.
(좌측) 좋아요 버튼
GestureDetector(
onTap: () {
_animationController
.animateTo((size.width + 200) * -1)
.whenComplete(() {
_animationController.value = 0;
index = index == 13 ? 1 : index + 1;
setState(() {});
});
},
child: AnimatedScale(
scale:
_animationController.value < 0 ? scale * 1.5 : 1,
duration: const Duration(milliseconds: 50),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withOpacity(
_animationController.value > 0
? 0
: _animationController.value.abs() /
_animationController.upperBound),
border:
Border.all(width: 1, color: Colors.green)),
child: Icon(
Icons.done,
color: Colors.lightGreen.withOpacity(
_animationController.value > 0
? 1
: 1 -
_animationController.value.abs() /
_animationController.upperBound),
size: 32,
),
),
),
),
- GestureDetector 위젯은 사용자의 터치 동작을 감지합니다. 사용자가 위젯을 탭할 때마다 onTap 콜백이 호출됩니다.
- onTap 콜백에서는 먼저 _animationController를 사용하여 애니메이션을 시작합니다. animateTo 메서드는 애니메이션을 특정 위치로 이동시키는 역할을 합니다. 여기서는 현재 화면 너비에 200을 더한 값에 -1을 곱한 위치로 이동하게 됩니다. 이렇게 함으로써 애니메이션은 화면 왼쪽으로 움직이게 됩니다.
- 애니메이션이 완료된 후에는 whenComplete 콜백이 호출됩니다. 여기서는 _animationController의 값을 0으로 다시 설정하고, 인덱스를 증가시킵니다. 만약 현재 인덱스가 13라면 다시 1로 변경하고, 그렇지 않다면 현재 인덱스를 1 증가시킵니다. 그리고 setState를 호출하여 화면을 업데이트합니다.
- AnimatedScale 위젯은 애니메이션을 통해 크기를 조절합니다. _animationController의 값이 0보다 작을 때 크기를 1.5배로 확대합니다.
- Container와 Icon 위젯은 애니메이션 컨트롤러의 값에 따라 색상을 변경합니다. Container의 색상은 애니메이션의 진행 정도에 따라 투명도가 변경되고, Icon의 색상은 반대로 변경되어 화면에 깜박이는 효과를 줍니다.
- 이렇게 함으로써 사용자가 화면을 탭할 때마다 아이콘을 포함한 위젯이 왼쪽으로 애니메이션되며, 동시에 크기와 색상도 변화하는 효과를 얻을 수 있습니다.
- 출처: 노마드코더
반응형
'앱 개발 > Flutter' 카테고리의 다른 글
Comments