주제 선정
[Flutter] 그라데이션으로 만들어보는 광원 효과
빛이 반사되는 효과는 어떻게 만들까?
velog.io
처음에 위의 글을 보고 애니메이션이 기깔난다고 생각을 하고있었다.
문득 카카오페이를 들어가봤는데 밑에 내가 추가한 카드들의 이미지가 뜨고 해당 카드들을 스크롤해서 카드의 정보를 확인할 수 있었다.
여기서 또 프론트 병이 생겨 궁금하다 이쁘다 만들어보고싶다 심심한데 이거 두개를 접목시켜서 만들어볼까? 라는 생각에서 시작되었다.
사실 이맛에 눈에 보이는 개발을 한다.
생각되는 방식과 이번 구현을 통한 나의 목표는
1. 새로운 카드 추가 기능(로컬 스트리지로 기기별 저장)
2. 리버팟 사용 연습
3. 카드 광원효과 적용
4. 카드 스크롤을 통해서 변경된 카드정보 확인(상단 흰색 부분에 카드정보 넣을 예정)
5. 사실 라이브러리가 이미 있긴 했는데 라이브러리 사용 최소화해서 만들기
총 3편으로 구성 예정이고
1편 - 카드 추가하기, 카드 회전 애니메이션, 로컬 스토리지를 통한 카드 정보 저장
2편 - 저장한 카드를 보여주는 화면, 각 카드별 그라데이션을 이용한 광원효과 줒기
3편 - River Pod 적용하기
이렇게 구현 및 작성 예정이다.
카드 모양 그리기
우선 카드 모양에 대해서 그려보려고한다.
제공하는 기본 도형을 사용하는 방법도 있고 여러가지 방법이 있겠지만, 이번에는 CustomPainter 복습겸 개인적으로 가장 이쁜 모양이라고 생각되는 토스카드의 모양을 따라서 그려보려고 한다.
토스 카드 모양을 보면 다음과 같이 생겼다.
그래서 일단 CustomPainter 로 만들기 위해서 스케치를 해본다.
우선 시작은 1번에서 부터 시작을 한다.
그다음 2번까지 이동
2번에서 3번으로는 대각선처리를 해주어야 한다.
그때 사용할수 있는게 quadraticBezierTo 를 사용하게 되는데 회색 점의 위치가 x1, y1이 들어가게 되고 3번 점의 위치가 x2, y2 가 들어가게 된다.
그다음 4번까지 이동
4번에서 5번까지의 방법도 이전과 동일
이렇게 나는 시계 방향으로 돌아가게 만들면서 쭈욱 1번까지 다시 돌아오게 되면 카드 기본 모양은 완성이된다.
class CardPainter extends CustomPainter {
final double cardWidth;
final double cardHeight;
final Color cardColor;
const CardPainter({
required this.cardWidth,
required this.cardHeight,
required this.cardColor,
});
@override
void paint(Canvas canvas, Size size) {
final backgroundPaint = Paint()
..color = cardColor
..style = PaintingStyle.fill;
// 모서리 곡률을 위한 제어점 거리
final controlPointDistance = cardWidth / 15;
// 모서리가 들어간 부분을 표현(카드 삼각형)
final controlDepth = cardHeight * 0.1;
final path = Path()
// 1번 포인트에서 시작
..moveTo(controlPointDistance, 0)
// 1-2번 직선
..lineTo(cardWidth - controlPointDistance, 0)
// 2-3번 곡선
..quadraticBezierTo(cardWidth, 0, cardWidth, controlPointDistance)
// 3-4번 직선
..lineTo(cardWidth, cardHeight / 2)
// 4-5번 곡선
..quadraticBezierTo(cardWidth - controlDepth, cardHeight / 2 + cardHeight * 0.05, cardWidth - controlDepth, cardHeight / 2 + cardHeight * 0.1)
// 5-6번 곡선
..quadraticBezierTo(cardWidth - controlDepth, cardHeight / 2 + cardHeight * 0.15, cardWidth, cardHeight / 2 + cardHeight * 0.2)
// 6-7번 직선
..lineTo(cardWidth, cardHeight - controlPointDistance)
// 7~8번 곡선
..quadraticBezierTo(cardWidth, cardHeight, cardWidth - controlPointDistance, cardHeight)
// 8-9번 직선
..lineTo(controlPointDistance, cardHeight)
// 9-10번 곡선
..quadraticBezierTo(0, cardHeight, 0, cardHeight - controlPointDistance)
// 10-11번 직선
..lineTo(0, controlPointDistance)
// 11~1번 곡선
..quadraticBezierTo(0, 0, controlPointDistance, 0)
..close();
canvas.drawPath(path, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
코드는 이렇게 나오게 되는데 여기서 의문이 들수 있는게 왜 제어점 거리와 모서리가 들어가는 부분을 표시하기위해 비율을 사용했는지 의문이 들 수 있다.
이렇게 만든 이유는 나중에 홈화면에서 카드정보를 보여줄때 필요하다고 생각해서 직접 값을 넣는게 아닌 비율로 동일한 UI를 만들려고 했다.
이미지와 같은 결과물을 얻을 수 있는데
다음으로 하고싶은건 카드번호를 입력할때 카드의 앞면에 입력되면서 보여지고
CVV번호를 입력할때는 카드의 뒷면이 보여지면서 입력되는 모습을 보여주고 싶었다.
그러면 우선 카드의 뒷면도 그려줘야한다.
똑같이 CustomPainter를 이용해서 그려줄수 있는데 이전 방법과 똑같이 비율을 사용하고 시계 방향으로 회전하면서 그려주고 뒷면에 있는 마그네틱과 서명란도 같이 넣어줬다.
class BackCardPainter extends CustomPainter {
final double cardWidth;
final double cardHeight;
final Color cardColor;
final Color stripeColor;
const BackCardPainter({
required this.cardWidth,
required this.cardHeight,
required this.cardColor,
this.stripeColor = Colors.black54,
});
@override
void paint(Canvas canvas, Size size) {
final backgroundPaint = Paint()
..color = cardColor
..style = PaintingStyle.fill;
final stripePaint = Paint()
..color = stripeColor
..style = PaintingStyle.fill;
final signaturePaint = Paint()
..color = Colors.white70
..style = PaintingStyle.fill;
// 모서리 곡률을 위한 제어점 거리
final controlPointDistance = cardWidth / 15;
// 모서리가 들어간 부분을 표현(카드 삼각형)
final controlDepth = cardHeight * 0.1;
// 카드 외곽선 그리기
final path = Path()
..moveTo(controlPointDistance, 0)
..lineTo(cardWidth - controlPointDistance, 0)
..quadraticBezierTo(cardWidth, 0, cardWidth, controlPointDistance)
..lineTo(cardWidth, cardHeight - controlPointDistance)
..quadraticBezierTo(cardWidth, cardHeight, cardWidth - controlPointDistance, cardHeight)
..lineTo(controlPointDistance, cardHeight)
..quadraticBezierTo(0, cardHeight, 0, cardHeight - controlPointDistance)
..lineTo(0, cardHeight / 2 + cardHeight * 0.2)
..quadraticBezierTo(controlDepth, cardHeight / 2 + cardHeight * 0.15, controlDepth, cardHeight / 2 + cardHeight * 0.1)
..quadraticBezierTo(controlDepth, cardHeight / 2 + cardHeight * 0.05, 0, cardHeight / 2)
..lineTo(0, controlPointDistance)
..quadraticBezierTo(0, 0, controlPointDistance, 0)
..close();
canvas.drawPath(path, backgroundPaint);
// 마그네틱 스트라이프 그리기
final stripeRect = Rect.fromLTWH(0, cardHeight * 0.15, cardWidth, cardHeight * 0.15);
canvas.drawRect(stripeRect, stripePaint);
// 서명란 그리기
final signatureRect = RRect.fromRectAndRadius(
Rect.fromLTWH(cardWidth * 0.1, cardHeight * 0.6, cardWidth * 0.7, cardHeight * 0.15,),
const Radius.circular(4),
);
canvas.drawRRect(signatureRect, signaturePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
다음과 같이 넣으면 이미지와 같은 결과를 얻을 수 있다.
그럼 다음으로 해야될건 CVV 입력란에서만 회전을 시켜 카드의 뒷면을 보여줘야 한다.
회전 효과 넣기
그냥 텍스트필드가 터치됐을때 뒷면을 보여주면 되긴하는데 이건 너무 안이쁘다
그래서 선택한 방법은 앞면 상태에서 CVV 텍스트 필드가 터치되었을때 180도 회전하면서 뒷면을 보여주는 것이다.
그러기 위해선 Matrix4 의 기본 원리를 알아야한다.
기초
네이버 사전에서 정의된
Matrix - (숫자·기호 등을 가로, 세로로 나열해 놓은) 행렬[매트릭스]
- 플러터는 사실 3D로 렌더링 된다 화면에만 2D로 보인다.
- 구글의 설명에 따르면 스마트폰의 GPU는 2D 렌더링 보다 3D 렌더링에 더 최적화 되어있고 더 빠르다.
- Matrix4는 3차원 좌표를 3차원 다른 좌표로 투영 시키는데 사용되는 matrix 이다.
그러면 플러터에서의 3D 좌표계 X, Y, Z 에 대한 이해가 필요하다
X 축 : 왼쪽에서 오른쪽 방향 (→)
Y 축 : 위에서 아래방향 (↓)
Z 축 : 화면에서 사용자를 향하는 방향 (↗)
일반적인 수학적 의미와 똑같다.
Matrix4 는?
학창시절 수학시간에 배웠던 4x4 의 행렬을 의미한다.
그 행렬의 값에 따라서 3D 공간에서 변환을 표현하는데 사용한다.
이 행렬은 이동, 회전, 크기 조절 등의 3D 변환을 수행할 수 있다.
Matrix4.identity()
를 통해서 다음 이미지와 같은 행렬을 만들 수 있고 각 행렬의 위치이다.
원근감 표현하기
회전할때 만약 원근감이 없다고 가정해보자
그러면 너무 밋밋하고 사용자의 입장에서 부자연스럽게 느껴질거다.
해당 원근감을 표현하기 위해선 아까 선언한 Matrix4에 setEntry 를 통해서 값을 넣어주면된다.
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
이런식으로 값을 넣어주면 원근감이 표현된다.
여기서 주의해야할점은 행과 열이 반대로 들어가게 된다는것이다. 너무나도 헷갈리게 만들어놨다...
해당 위치에 값이 들어가게 되면서 Z 축에 대한 원근감이 이루어 지게 되는데
하지만 항상 Z 축에만 원근감을 줄수 있을까? 당연히 아니다.
상황에 따라서 X 축 원근감이나 Y 축 원근감을 줄 수도있는데 다음 그림과 같이 위치시키면된다
회전시키기
사실 이미지로만 보면 벌써 어지럽다....
나는 개발을하고싶은데 수학을 해야한다
하지만 그럴필요 없이 다음과 같이 사용해주면 된다.
..rotateX(3.14159 * _animation.value),
..rotateY(3.14159 * _animation.value),
..rotateZ(3.14159 * _animation.value),
그러면 X축, Y축, Z축 회전에 대해서 훨씬 쉽게 구현할 수 있다.
그러면 지금까지의 내용을 종합해서 내가 필요한 부분을 만들어 보면 다음과 같다
return Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.0001)
..rotateY(3.14159 * _animation.value),
이렇게 만들어서 카드 회전을 적용해주면 된다.
그러기 위해선 애니메이션 값을 0.6초 동안 0에서 1까지 값의 변동을 주고
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_animation = Tween<double>(
begin: 0,
end: 1,
).animate(_animationController);
}
삼항 연산자를 통해서 카드의 앞면을 보여줄지 뒷면을 보여줄지 설정해주었다.
_animation.value < 0.5 ? 카드의 앞면() : 카드의 뒷면()
이렇게 화면의 표시에 대해서 만들어주고 별도 함수를 통해서 애니메이션에 대해서 컨트롤 해주었다.
void _flipCard() {
if (isBackSide) {
_animationController.reverse();
} else {
_animationController.forward();
}
setState(() {
isBackSide = !isBackSide;
});
}
사실 Matrix 는 지금까지 써본적 없어서 공부하는데 시간이 조금 걸렸었다.
근데 기본적인 예제들도 많고 웬만한 부분은 다 제공해줘서 개념을 이해하고 사용만 하면 쉬운거 같다.
로컬 스토리지 저장하기
로컬 스토리지도 종료가 엄청 다양하다
Hive, Shared preferences, SQFlite 등이 대표적이라고 생각되는데
Hive 는 현재 기준으로 3년동안 업데이트가 없다. 그래서 나는 Hive는 별로 안좋아하고
Shared preferences는 관계형이 아니기 때문에 탈락
이번에는 SQFlite를 사용하려고 하는데 디테일한 설명까지 들어가면 너무 길어질거 같아 간략하게 구현하려고 한다.
그래서 모델도 안만들었다.
우선 SQFlite 설치는 패스하고
로컬 데이터 베이스를 초기화 하면서 만들어주고,
홈화면에서 필요한 조회, 삭제
카드 추가에서 필요한 추가
부분만 간략하게 구현하게되면 다음과 같다
class DatabaseHelper {
static const _databaseName = "cardDatabase.db";
static const _databaseVersion = 1;
// DatabaseHelper를 싱글턴으로 하여 데이터베이스 인스턴스가
// 한번만 초기화 되도록함
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
// getDatabasesPath()로 가져온 데이터베이스 경로와
// 데이터베이스의 이름을 합쳐서 경로로 설정
String path = join(await getDatabasesPath(), _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
// 데이터베이스 생성시 실행할 SQL
onCreate: (db, version) {
db.execute('''
CREATE TABLE cards (
_id TEXT NOT NULL UNIQUE,
card_name TEXT NOT NULL,
card_number TEXT NOT NULL,
card_expiration_date TEXT NOT NULL,
card_cvv TEXT NOT NULL,
card_color TEXT NOT NULL,
dateTime TEXT,
PRIMARY KEY(_id)
)
''');
},
);
}
// 카드 추가
Future<int> insertCard(Map<String, dynamic> card) async {
Database db = await database;
return await db.insert(
'cards',
card,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// 모든 카드 조회
Future<List<Map<String, dynamic>>> getAllCards() async {
Database db = await database;
return await db.query('cards', orderBy: 'dateTime DESC');
}
// 카드 삭제
Future<int> deleteCard(String id) async {
Database db = await database;
return await db.delete(
'cards',
where: '_id = ?',
whereArgs: [id],
);
}
}
그 다음 카드 추가하는 화면에서는 다음과 같이 만들어주면 된다.
Future<void> _saveCard() async {
// 카드 데이터 유효성 검사
if (cardNumber.isEmpty ||
cardHolderName.isEmpty ||
cardExpirationDate.isEmpty ||
cardCvv.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('모든 필드를 입력해주세요.')),
);
return;
}
try {
final cardData = {
'_id': _uuid.v4(), // 고유 ID 생성
'card_name': cardHolderName,
'card_number': cardNumber,
'card_expiration_date': cardExpirationDate,
'card_cvv': cardCvv,
'card_color': '#0000FF', // 파란색
'dateTime': DateTime.now().toIso8601String(),
};
await _databaseHelper.insertCard(cardData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('카드가 성공적으로 저장되었습니다.')),
);
MyRouter.router.go('/');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('카드 저장 중 오류가 발생했습니다: $e')),
);
}
}
}
그리고 아직 홈화면이 구현되지 않았지만 동일하게 사용하면 된다.
마무리 디테일 작업
이제 카드 저장 페이지에 대한 기초적인 애니메이션과 기본 구현은 다 완성했다.
그럼 이제 해야될건? 조금더 디테일하게 들어가기!!
예를들면 카드 번호를 칠때 4자리씩 끊어서 - 를 넣어준다거나
카드 만료일을 넣을때 2자리씩 끊어서 / 를 넣어주는 것과 같이 마무리 완성도를 조금 높여보려고 한다.
사실 어려운건 없고 RegExp 정규식 표현을 사용해서 우선 숫자가 아닌 모든 문자열을 제거해준다.
그 다음 카드번호는 4자리씩 끊어서 - 를 넣어주고 만료일은 2자리씩 끊어서 / 를 넣어 준 다음
각각의 텍스트필드 onChanged에 넣어주기만 하면 된다.
String _formatCardNumber(String number) {
final digitsOnly = number.replaceAll(RegExp(r'[^\d]'), '');
final chunks = <String>[];
for (var i = 0; i < digitsOnly.length; i += 4) {
final end = i + 4;
chunks.add(digitsOnly.substring(i, end > digitsOnly.length ? digitsOnly.length : end));
}
return chunks.join('-');
}
String _formatCardExpirationDate(String date) {
final digitsOnly = date.replaceAll(RegExp(r'[^\d]'), '');
final chunks = <String>[];
for (var i = 0; i < digitsOnly.length; i += 2) {
final end = i + 2;
chunks.add(digitsOnly.substring(i, end > digitsOnly.length ? digitsOnly.length : end));
}
return chunks.join('/');
}
추가 작업
막상 작업을 하니까 토스 카드모양과 약간 다르게 생긴게 신경쓰여 추가 작업에 들어갔다.
기존에 작성한 내용처럼 quadraticBezierTo 를 사용하는건 동일하지만 약간의 내용 변경이 있다.
이미지를 봐도 차이가 크게 느껴진다.
그러기 위해선 기존의 방법에서 추가적으로 작업이 들어가야 되는 부분이 다음과 같다고 생각했다.
해당 내용을 적용해서 바꿔보면 이런식으로 그릴수 있고
다음과 같은 코드가 만들어진다.
void paint(Canvas canvas, Size size) {
final backgroundPaint = Paint()
..color = cardColor
..style = PaintingStyle.stroke
..strokeWidth = 5;
// 모서리 곡률을 위한 제어점 거리
final controlPointDistance = cardWidth / 15;
final cardDepth = cardHeight / 40;
final path = Path()
// 1번 포인트에서 시작
..moveTo(controlPointDistance, 0)
// 1-2번 직선
..lineTo(cardWidth - controlPointDistance, 0)
// 2-3번 곡선
..quadraticBezierTo(cardWidth, 0, cardWidth, controlPointDistance)
//
..lineTo(cardWidth, cardHeight / 2 - cardDepth)
..quadraticBezierTo(cardWidth, cardHeight / 2, cardWidth - 10,
cardHeight / 2 + cardDepth * 2)
..lineTo(cardWidth - 10, cardHeight / 2 + cardDepth * 2)
..quadraticBezierTo(
cardWidth - cardDepth * 4,
cardHeight / 2 + cardDepth * 4,
cardWidth - cardDepth * 2,
cardHeight / 2 + cardDepth * 6)
..lineTo(cardWidth - cardDepth * 2, cardHeight / 2 + cardDepth * 6)
..quadraticBezierTo(cardWidth, cardHeight / 2 + cardDepth * 9, cardWidth,
cardHeight / 2 + cardDepth * 10)
..lineTo(cardWidth, cardHeight / 2 + cardDepth * 10)
// 6-7번 직선
..lineTo(cardWidth, cardHeight - controlPointDistance)
// 7~8번 곡선
..quadraticBezierTo(
cardWidth, cardHeight, cardWidth - controlPointDistance, cardHeight)
// 8-9번 직선
..lineTo(controlPointDistance, cardHeight)
// 9-10번 곡선
..quadraticBezierTo(0, cardHeight, 0, cardHeight - controlPointDistance)
// 10-11번 직선
..lineTo(0, controlPointDistance)
// 11~1번 곡선
..quadraticBezierTo(0, 0, controlPointDistance, 0)
..close();
canvas.drawPath(path, backgroundPaint);
}
CustomPainter를 하면서 조금더 편하게 그리는 방법들은 물론 내 기준이다 ㅎㅎ
1. 색을 채우지말고 PaintingStyle.stroke 를 이용해서 어떻게 선이 그려지고 있는지 본다
final backgroundPaint = Paint()
..color = cardColor
..style = PaintingStyle.stroke
..strokeWidth = 5;
2. 곡선을 줘야할 경우 우선 lineTo 로 대략적인 모양을 그리고 곡선을 이용해라
3. 손 스케치를 그리는게 제일 정확하다
이렇게 세가지를 느끼게됐다.
결론
이번에 작업하면서 CustomPainter는 복습을 했던 시간이고, Martrix4 는 처음으로 공부했던 시간 이였다.
애니메이션이라던지 CustomPainter를 작업한다던지 수학이 들어가면 머리가 아파지는거 같다.
개인적으로 아쉽지만 구현 못한 부분 (카드번호 특정 범위 가려주기, 로컬 DB의 구체화 등) 도 있었다.
코드
https://github.com/Hsungjin/Flutter/tree/main/personal_study/creadit_card_example
Flutter/personal_study/creadit_card_example at main · Hsungjin/Flutter
Contribute to Hsungjin/Flutter development by creating an account on GitHub.
github.com
참고
https://medium.com/@ExplosivePasta/flutter-sqflite-afaa734f4426
Flutter — SQFlite
Flutter로 모바일 앱을 개발할 때, 데이터를 로컬에 저장하는 것은 매우 중요한 기능 중 하나입니다. 이를 위해 SQLite 데이터베이스를 사용할 수 있으며, Flutter에서는 SQFlite를 사용하여 쉽게 데이터
medium.com
[Flutter] CustomPaint로 카드 만들기
토스뱅크카드 토스뱅크카드는 다른 카드와 다르게 움푹파인 부분이 있습니다. 이를 코드로 구현하고 싶어서 만들게 되었습니다. 토스뱅크카드 출처: https://www.tossbank.com/product-service/card/check-card
velog.io
https://lucky516.tistory.com/123
[Flutter] Matrix4 이해하기
https://medium.com/flutter-community/advanced-flutter-matrix4-and-perspective-transformations-a79404a0d828 Advanced Flutter: Matrix4 And Perspective Transformations Demystifying Matrix4 and utilising the full power of the Transform Widget medium.com https:
lucky516.tistory.com
'Flutter' 카테고리의 다른 글
Flutter 신용카드 예제 Like 카카오페이 2편 (0) | 2025.03.05 |
---|---|
Flutter Papago API 를 활용한 번역 서비스 만들기 (1) | 2025.02.26 |
Flutter 의 annotation 에 대해 (0) | 2025.02.12 |
Flutter Firebase Crashlytics 적용해보기 (0) | 2025.02.06 |
Flutter 에서의 비동기 프로그래밍 Future, async/await (0) | 2025.01.31 |