아래과 같은 디자인을 구현하는 과정에 대한 글이다.
이 디자인을 보고 어떻게 해야할 지 좀 막막했는데, 다행히 대략 비슷하게 나온 것 같아서 정리 차원에서 남겨놓는다.
일단 화면에서 보라색 레이어를 만드는 것이 가장 큰 과제가 되겠다.
특정 부분이 투명처리 된 위젯을 만들어서 하단 (얼굴 애니메이션 --> 로고 및 로그인 화면) 페이지 위에 Stack으로 얹으면 될 것 같다.
일단 두개의 Container를 가진 Stack을 만들어 보자.
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Container(color: Colors.amber),
Container(color: Colors.purple),
],
);
}
}
여기서 보라색에 구멍만 뚫으면 어떻게 될 것 같다. (말은 쉽다만...)
일단 ClipPath를 쓰면 될 것 같으니, 아무 구멍이나 한번 뚫어 보자.
class _SplashPageState extends State<SplashPage> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Container(color: Colors.amber),
// CustomClipper를 하나 만들어서 보라색 레이어를 클립
ClipPath(clipper: MyClipper(), child: Container(color: Colors.purple)),
],
);
}
}
class MyClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
// 일단 원형 중앙에 반지름 20짜리 원을 하나 만들었음.
return Path()
..addOval(Rect.fromCircle(
center: Offset(size.width / 2, size.height / 2), radius: 20));
}
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}
원 안이 투명하고 밖이 보라색이어야 하는데, 반대로 됐다.
이 부분이 어떻게 해야할 지 잘 안 떠올랐는데, 이렇게 처리했다.
class MyClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
// 화면을 꽉 채우는 사각형과 중앙의 원 사이의 차이를 이용해서 새로운 Path를 만든다.
return Path.combine(
PathOperation.difference,
Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)),
Path()
..addOval(Rect.fromCircle(center: Offset(size.width / 2, size.height / 2),
radius: 20)),
);
}
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}
Path.combine() 을 이용해서 전체 화면 (Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) 에서 가운데 원 부분을 빼준다.
이렇게 하면
이제 시시한 원 말고 패턴을 넣어 보자.
Path()..addOval() 대신에 이 svg 를 Path로 변환해서 넣어주면 될 것 같다!
svg 를 Path로 변환하는 작업은 path_drawing(https://pub.dev/packages/path_drawing) 패키지를 이용했다.
Path pattern = parseSvgPathData(
'M 2.7831498,19.823229 C 9.3024808,9.2505269 26.327153,10.029567 36.524345,13.770124 c 3.92713,1.441369 7.86004,3.463555 12.02464,3.151452 3.65682,-0.274175 6.95549,-2.345308 9.74251,-4.766569 2.78799,-2.4212621 5.22458,-5.2469611 8.14096,-7.4999341 3.46664,-2.676424 7.66406,-4.51140698 12.01691,-4.64712998 5.14928,-0.160195 10.23678,2.11452098 13.93125,5.75728498 3.69446,3.642764 6.04031,8.5640691 7.0356,13.6943191 1.984785,10.227937 -1.37468,21.093827 -7.74419,29.271886 -6.3695,8.179424 -15.49707,13.838432 -25.1942,17.277312 -10.90674,3.86887 -22.80299,5.08764 -34.140281,2.82853 C 24.076879,67.192425 16.011219,63.598505 10.113005,57.504643 3.8050898,50.979825 0.35688779,41.864383 0.02508979,32.724957 c -0.125691,-3.517962 0.197998,-7.106418 1.49275101,-10.37185 0.358441,-0.906668 0.783783,-1.748108 1.265309,-2.529878 z');
parseSvgPathData() 의 파라미터로 들어가는 스트링은 .svg 파일에서 <path d="여기여기"> 부분을 사용하면 된다.
class MyClipper extends CustomClipper<Path> {
Path pattern = parseSvgPathData(
'M 2.7831498,19.823229 C 9.3024808,9.2505269 26.327153,10.029567 36.524345,13.770124 c 3.92713,1.441369 7.86004,3.463555 12.02464,3.151452 3.65682,-0.274175 6.95549,-2.345308 9.74251,-4.766569 2.78799,-2.4212621 5.22458,-5.2469611 8.14096,-7.4999341 3.46664,-2.676424 7.66406,-4.51140698 12.01691,-4.64712998 5.14928,-0.160195 10.23678,2.11452098 13.93125,5.75728498 3.69446,3.642764 6.04031,8.5640691 7.0356,13.6943191 1.984785,10.227937 -1.37468,21.093827 -7.74419,29.271886 -6.3695,8.179424 -15.49707,13.838432 -25.1942,17.277312 -10.90674,3.86887 -22.80299,5.08764 -34.140281,2.82853 C 24.076879,67.192425 16.011219,63.598505 10.113005,57.504643 3.8050898,50.979825 0.35688779,41.864383 0.02508979,32.724957 c -0.125691,-3.517962 0.197998,-7.106418 1.49275101,-10.37185 0.358441,-0.906668 0.783783,-1.748108 1.265309,-2.529878 z');
@override
Path getClip(Size size) {
return Path.combine(
PathOperation.difference,
Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)),
pattern,
);
}
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}
이렇게 넣어주면!
자, 이제 새로 만든 Path의 위치를 조절해보자.
@override
Path getClip(Size size) {
// 패턴 Path를 화면 가운데로 이동
Path path = Path()
..addPath(
pattern,
Offset((size.width - pattern.getBounds().width) / 2,
(size.height - pattern.getBounds().height) / 2));
return Path.combine(
PathOperation.difference,
Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)),
path,
);
}
pattern.getBounds() 로 현재 패턴의 크기를 가져왔다는 것만 알면 코드는 설명할 게 없다.
이제 애니메이션을 주면서 패턴의 크기를 키워보자.
class _SplashPageState extends State<SplashPage> with TickerProviderStateMixin {
// 1초간 0에서 1까지 진행하는 animationController 를 하나 만든다.
late final AnimationController animationController =
AnimationController(vsync: this, duration: const Duration(seconds: 1));
@override
void initState() {
super.initState();
animationController.forward();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Container(color: Colors.amber),
// 100 에서 250까지 패턴 크기가 변경되도록 한다.
AnimatedBuilder(
animation: animationController,
builder: (BuildContext context, Widget? child) {
return ClipPath(
clipper: MyClipper(animationController.value * 150 + 100, 100),
child: Container(color: Colors.purple));
}),
],
);
}
}
class MyClipper extends CustomClipper<Path> {
final double clipSize;
final double startSize;
// 현재 크기와 초기 크기를 이용해서 크기를 계산한다.
MyClipper(
this.clipSize,
this.startSize,
);
Path pattern = parseSvgPathData(
'M 2.7831498,19.823229 C 9.3024808,9.2505269 26.327153,10.029567 36.524345,13.770124 c 3.92713,1.441369 7.86004,3.463555 12.02464,3.151452 3.65682,-0.274175 6.95549,-2.345308 9.74251,-4.766569 2.78799,-2.4212621 5.22458,-5.2469611 8.14096,-7.4999341 3.46664,-2.676424 7.66406,-4.51140698 12.01691,-4.64712998 5.14928,-0.160195 10.23678,2.11452098 13.93125,5.75728498 3.69446,3.642764 6.04031,8.5640691 7.0356,13.6943191 1.984785,10.227937 -1.37468,21.093827 -7.74419,29.271886 -6.3695,8.179424 -15.49707,13.838432 -25.1942,17.277312 -10.90674,3.86887 -22.80299,5.08764 -34.140281,2.82853 C 24.076879,67.192425 16.011219,63.598505 10.113005,57.504643 3.8050898,50.979825 0.35688779,41.864383 0.02508979,32.724957 c -0.125691,-3.517962 0.197998,-7.106418 1.49275101,-10.37185 0.358441,-0.906668 0.783783,-1.748108 1.265309,-2.529878 z');
@override
Path getClip(Size size) {
// 원하는 크기에 맞게 패턴 Path를 scale
final Matrix4 matrix4 = Matrix4.identity();
matrix4.scale(clipSize / startSize);
pattern = pattern.transform(matrix4.storage);
// 스케일 한 패턴 Path를 화면 가운데로 이동
Path path = Path()
..addPath(
pattern,
Offset((size.width - pattern.getBounds().width) / 2,
(size.height - pattern.getBounds().height) / 2));
return Path.combine(
PathOperation.difference,
Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)),
path,
);
}
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => true;
}
이제 투명한 패턴이 점점 커지는 모양이 만들어졌다.
마지막으로 보라색 패턴이 사라지도록 해주기만 하면 다른 부분은 그냥 넣어주기만 하면 될 것 같다.
return ClipPath(
clipper:
MyClipper(animationController.value * 150 + 100, 100),
child: Container(
color: Colors.purple
.withOpacity(1 - animationController.value)));
이렇게만 해주면 끝!!!
이제 세세한 부분만 잡아주면 끝! : )
하나하나 보면 별 것 아닌데, 처음에 어떻게 해야할 지 감이 잘 안잡혀서 오래 걸렸다.