본문 바로가기

일::개발

Flutter: 배경에 패턴 구멍내기. Masking Widgets

아래과 같은 디자인을 구현하는 과정에 대한 글이다.

이 디자인을 보고 어떻게 해야할 지 좀 막막했는데, 다행히 대략 비슷하게 나온 것 같아서 정리 차원에서 남겨놓는다.

 

 

일단 화면에서 보라색 레이어를 만드는 것이 가장 큰 과제가 되겠다.

특정 부분이 투명처리 된 위젯을 만들어서 하단 (얼굴 애니메이션 --> 로고 및 로그인 화면) 페이지 위에 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)) 에서 가운데 원 부분을 빼준다. 

이렇게 하면

오! 뚫렸다!

이제 시시한 원 말고 패턴을 넣어 보자.

이런 패턴을 svg 파일로 받았습니다.

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)));

 

이렇게만 해주면 끝!!!

 

어려운 것은 끝!

 

이제 세세한 부분만 잡아주면 끝! : )

하나하나 보면 별 것 아닌데, 처음에 어떻게 해야할 지 감이 잘 안잡혀서 오래 걸렸다.