개요

ClipOval은 자식 위젯을 타원형(ellipse) 또는 원형(circle)으로 잘라내는 위젯입니다. 주로 프로필 이미지나 아바타를 원형으로 만들거나, 디자인적으로 타원형 모양이 필요한 경우에 사용됩니다.

A widget that clips its child using an oval shape.

Flutter 참조: https://api.flutter.dev/flutter/widgets/ClipOval-class.html

언제 사용하나요?

  • 프로필 이미지나 아바타를 원형으로 만들 때
  • 이미지나 컨테이너를 타원형으로 잘라내고 싶을 때
  • 버튼이나 카드를 원형 또는 타원형으로 만들 때
  • 디자인적으로 부드러운 곡선 모양이 필요할 때
  • 로고나 아이콘을 원형 배경에 넣을 때
  • 동그란 상태 표시기나 배지를 만들 때

기본 사용법

import { ClipOval, Container, Image } from '@meursyphus/flitter';

// 기본 원형 클립
ClipOval({
  clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
  child: Container({
    width: 100,
    height: 100,
    color: "blue",
    child: Center({
      child: Text("원형", { style: TextStyle({ color: "white", fontSize: 16 }) }),
    }),
  }),
});

// 이미지를 원형으로 클립
ClipOval({
  clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
  child: Image({
    src: "https://example.com/profile.jpg",
    width: 80,
    height: 80,
    fit: "cover",
  }),
});

Props

child (필수)

값: Widget

잘라낼 자식 위젯입니다.

clipper (필수)

값: (size: Size) => Rect

클립 영역을 정의하는 함수입니다. 위젯의 크기를 받아서 타원이 그려질 사각형 영역을 반환합니다.

  • Rect.fromLTWH(0, 0, size.width, size.height): 전체 영역을 타원으로 클립
  • Rect.fromLTWH(10, 10, size.width-20, size.height-20): 여백을 둔 타원 클립

clipped (선택)

값: boolean (기본값: true)

클리핑 효과를 활성화할지 여부를 지정합니다.

  • true: 클리핑 적용
  • false: 클리핑 비활성화 (자식 위젯이 원래대로 표시됨)

key (선택)

값: any

위젯의 고유 식별자입니다.

실제 사용 예제

예제 1: 프로필 아바타 목록

import { ClipOval, Container, Image, Row, Column } from '@meursyphus/flitter';

class ProfileAvatars extends StatelessWidget {
  profiles = [
    { name: "김철수", image: "https://picsum.photos/200/200?random=1", status: "online" },
    { name: "이영희", image: "https://picsum.photos/200/200?random=2", status: "offline" },
    { name: "박민수", image: "https://picsum.photos/200/200?random=3", status: "away" },
    { name: "최지은", image: "https://picsum.photos/200/200?random=4", status: "online" },
  ];

  build() {
    return Column({
      children: [
        Text(
          "팀 멤버",
          { style: TextStyle({ fontSize: 20, fontWeight: "bold", marginBottom: 20 }) }
        ),
        Row({
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: this.profiles.map((profile, index) => {
            return Column({
              key: ValueKey(index),
              children: [
                Stack({
                  children: [
                    ClipOval({
                      clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
                      child: Container({
                        width: 60,
                        height: 60,
                        decoration: BoxDecoration({
                          border: Border.all({
                            color: profile.status === "online" ? "#4CAF50" : "#757575",
                            width: 3,
                          }),
                        }),
                        child: Image({
                          src: profile.image,
                          fit: "cover",
                        }),
                      }),
                    }),
                    // 상태 표시 점
                    Positioned({
                      right: 2,
                      bottom: 2,
                      child: ClipOval({
                        clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
                        child: Container({
                          width: 16,
                          height: 16,
                          color: profile.status === "online" ? "#4CAF50" : 
                                 profile.status === "away" ? "#FF9800" : "#757575",
                          child: Container({
                            margin: EdgeInsets.all(2),
                            decoration: BoxDecoration({
                              color: "white",
                              borderRadius: BorderRadius.circular(8),
                            }),
                          }),
                        }),
                      }),
                    }),
                  ],
                }),
                SizedBox({ height: 8 }),
                Text(
                  profile.name,
                  { style: TextStyle({ fontSize: 12, textAlign: "center" }) }
                ),
              ],
            });
          }),
        }),
      ],
    });
  }
}

예제 2: 다양한 크기의 원형 버튼

import { ClipOval, Container, GestureDetector, Icon } from '@meursyphus/flitter';

class CircularButtons extends StatelessWidget {
  buttons = [
    { icon: Icons.home, color: "#2196F3", size: 80, label: "" },
    { icon: Icons.search, color: "#4CAF50", size: 60, label: "검색" },
    { icon: Icons.favorite, color: "#E91E63", size: 70, label: "좋아요" },
    { icon: Icons.settings, color: "#FF9800", size: 65, label: "설정" },
    { icon: Icons.add, color: "#9C27B0", size: 75, label: "추가" },
  ];

  build() {
    return Column({
      children: [
        Text(
          "원형 액션 버튼",
          { style: TextStyle({ fontSize: 20, fontWeight: "bold", marginBottom: 30 }) }
        ),
        Wrap({
          spacing: 20,
          runSpacing: 20,
          alignment: WrapAlignment.center,
          children: this.buttons.map((button, index) => {
            return Column({
              key: ValueKey(index),
              children: [
                GestureDetector({
                  onTap: () => {
                    console.log(`${button.label} 버튼 클릭됨`);
                  },
                  child: ClipOval({
                    clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
                    child: Container({
                      width: button.size,
                      height: button.size,
                      decoration: BoxDecoration({
                        gradient: RadialGradient({
                          colors: [button.color, this.darkenColor(button.color)],
                          stops: [0.0, 1.0],
                        }),
                        boxShadow: [
                          BoxShadow({
                            color: "rgba(0,0,0,0.3)",
                            blurRadius: 8,
                            offset: Offset({ x: 0, y: 4 }),
                          }),
                        ],
                      }),
                      child: Center({
                        child: Icon({
                          icon: button.icon,
                          color: "white",
                          size: button.size * 0.4,
                        }),
                      }),
                    }),
                  }),
                }),
                SizedBox({ height: 8 }),
                Text(
                  button.label,
                  { style: TextStyle({ fontSize: 12, textAlign: "center" }) }
                ),
              ],
            });
          }),
        }),
      ],
    });
  }

  darkenColor(color: string): string {
    // 색상을 어둡게 만드는 간단한 함수
    const colors: { [key: string]: string } = {
      "#2196F3": "#1976D2",
      "#4CAF50": "#388E3C",
      "#E91E63": "#C2185B",
      "#FF9800": "#F57C00",
      "#9C27B0": "#7B1FA2",
    };
    return colors[color] || color;
  }
}

예제 3: 프로그레스 링과 원형 차트

import { ClipOval, Container, Stack, CustomPaint } from '@meursyphus/flitter';

class CircularProgress extends StatefulWidget {
  createState() {
    return new CircularProgressState();
  }
}

class CircularProgressState extends State<CircularProgress> {
  progresses = [
    { label: "HTML", value: 0.9, color: "#E34F26" },
    { label: "CSS", value: 0.85, color: "#1572B6" },
    { label: "JavaScript", value: 0.8, color: "#F7DF1E" },
    { label: "React", value: 0.75, color: "#61DAFB" },
  ];

  build() {
    return Column({
      children: [
        Text(
          "기술 숙련도",
          { style: TextStyle({ fontSize: 20, fontWeight: "bold", marginBottom: 30 }) }
        ),
        GridView.count({
          crossAxisCount: 2,
          crossAxisSpacing: 20,
          mainAxisSpacing: 20,
          shrinkWrap: true,
          children: this.progresses.map((progress, index) => {
            return Column({
              key: ValueKey(index),
              children: [
                Container({
                  width: 120,
                  height: 120,
                  child: Stack({
                    alignment: Alignment.center,
                    children: [
                      // 배경 원
                      ClipOval({
                        clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
                        child: Container({
                          width: 120,
                          height: 120,
                          color: "#F5F5F5",
                        }),
                      }),
                      // 프로그레스 원
                      ClipOval({
                        clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
                        child: Container({
                          width: 120,
                          height: 120,
                          child: CustomPaint({
                            painter: CircularProgressPainter({
                              progress: progress.value,
                              color: progress.color,
                            }),
                          }),
                        }),
                      }),
                      // 중앙 텍스트
                      ClipOval({
                        clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
                        child: Container({
                          width: 80,
                          height: 80,
                          color: "white",
                          child: Center({
                            child: Text(
                              `${Math.round(progress.value * 100)}%`,
                              { style: TextStyle({ 
                                fontSize: 16, 
                                fontWeight: "bold",
                                color: progress.color 
                              }) }
                            ),
                          }),
                        }),
                      }),
                    ],
                  }),
                }),
                SizedBox({ height: 12 }),
                Text(
                  progress.label,
                  { style: TextStyle({ fontSize: 14, fontWeight: "bold", textAlign: "center" }) }
                ),
              ],
            });
          }),
        }),
      ],
    });
  }
}

class CircularProgressPainter extends CustomPainter {
  progress: number;
  color: string;

  constructor({ progress, color }: { progress: number; color: string }) {
    super();
    this.progress = progress;
    this.color = color;
  }

  paint(canvas: Canvas, size: Size) {
    const center = Offset(size.width / 2, size.height / 2);
    const radius = size.width / 2 - 10;

    // 프로그레스 호 그리기
    const paint = Paint()
      .setColor(this.color)
      .setStyle(PaintingStyle.stroke)
      .setStrokeWidth(8)
      .setStrokeCap(StrokeCap.round);

    const startAngle = -Math.PI / 2;
    const sweepAngle = 2 * Math.PI * this.progress;

    canvas.drawArc(
      Rect.fromCircle(center, radius),
      startAngle,
      sweepAngle,
      false,
      paint
    );
  }

  shouldRepaint(oldDelegate: CircularProgressPainter): boolean {
    return oldDelegate.progress !== this.progress || oldDelegate.color !== this.color;
  }
}

예제 4: 원형 이미지 갤러리

import { ClipOval, Container, GestureDetector, Image, PageView } from '@meursyphus/flitter';

class CircularImageGallery extends StatefulWidget {
  createState() {
    return new CircularImageGalleryState();
  }
}

class CircularImageGalleryState extends State<CircularImageGallery> {
  selectedIndex = 0;
  images = [
    { url: "https://picsum.photos/400/400?random=1", title: "자연 풍경" },
    { url: "https://picsum.photos/400/400?random=2", title: "도시 야경" },
    { url: "https://picsum.photos/400/400?random=3", title: "산속 길" },
    { url: "https://picsum.photos/400/400?random=4", title: "해변가" },
    { url: "https://picsum.photos/400/400?random=5", title: "숲속" },
    { url: "https://picsum.photos/400/400?random=6", title: "별밤" },
  ];

  build() {
    return Column({
      children: [
        Text(
          "원형 이미지 갤러리",
          { style: TextStyle({ fontSize: 20, fontWeight: "bold", marginBottom: 20 }) }
        ),
        
        // 메인 이미지
        Container({
          margin: EdgeInsets.symmetric({ horizontal: 40 }),
          child: ClipOval({
            clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
            child: Container({
              width: 250,
              height: 250,
              decoration: BoxDecoration({
                boxShadow: [
                  BoxShadow({
                    color: "rgba(0,0,0,0.3)",
                    blurRadius: 20,
                    offset: Offset({ x: 0, y: 10 }),
                  }),
                ],
              }),
              child: Image({
                src: this.images[this.selectedIndex].url,
                fit: "cover",
              }),
            }),
          }),
        }),
        
        SizedBox({ height: 20 }),
        Text(
          this.images[this.selectedIndex].title,
          { style: TextStyle({ fontSize: 18, fontWeight: "bold", textAlign: "center" }) }
        ),
        
        SizedBox({ height: 30 }),
        
        // 썸네일 목록
        Container({
          height: 80,
          child: ListView.builder({
            scrollDirection: Axis.horizontal,
            itemCount: this.images.length,
            padding: EdgeInsets.symmetric({ horizontal: 20 }),
            itemBuilder: (context, index) => {
              const isSelected = index === this.selectedIndex;
              
              return GestureDetector({
                onTap: () => {
                  this.setState(() => {
                    this.selectedIndex = index;
                  });
                },
                child: Container({
                  margin: EdgeInsets.symmetric({ horizontal: 8 }),
                  child: ClipOval({
                    clipper: (size) => Rect.fromLTWH(0, 0, size.width, size.height),
                    child: Container({
                      width: 60,
                      height: 60,
                      decoration: BoxDecoration({
                        border: isSelected ? Border.all({
                          color: "#2196F3",
                          width: 3,
                        }) : null,
                        boxShadow: isSelected ? [
                          BoxShadow({
                            color: "rgba(33, 150, 243, 0.4)",
                            blurRadius: 8,
                            offset: Offset({ x: 0, y: 4 }),
                          }),
                        ] : [
                          BoxShadow({
                            color: "rgba(0,0,0,0.1)",
                            blurRadius: 4,
                            offset: Offset({ x: 0, y: 2 }),
                          }),
                        ],
                      }),
                      child: Image({
                        src: this.images[index].url,
                        fit: "cover",
                      }),
                    }),
                  }),
                }),
              });
            },
          }),
        }),
        
        SizedBox({ height: 20 }),
        Text(
          `${this.selectedIndex + 1} / ${this.images.length}`,
          { style: TextStyle({ fontSize: 14, color: "#666", textAlign: "center" }) }
        ),
      ],
    });
  }
}

주의사항

  • 클리핑 성능: 복잡한 모양의 클리핑은 렌더링 성능에 영향을 줄 수 있습니다
  • 경계 처리: 클립된 영역 밖의 터치 이벤트는 감지되지 않습니다
  • 애니메이션: 클리핑과 애니메이션을 함께 사용할 때는 성능을 고려해야 합니다
  • clipper 함수: clipper 함수는 위젯 크기가 변경될 때마다 호출되므로 무거운 연산은 피해야 합니다
  • 중첩 클리핑: 여러 클리핑 위젯을 중첩할 때는 성능 저하가 있을 수 있습니다

관련 위젯

  • ClipRRect: 둥근 모서리로 위젯을 잘라내는 위젯
  • ClipRect: 사각형으로 위젯을 잘라내는 위젯
  • ClipPath: 사용자 정의 경로로 위젯을 잘라내는 위젯
  • Container: decoration의 shape 속성으로 원형 모양을 만들 수 있는 위젯
  • CircleAvatar: 원형 아바타를 만드는 전용 위젯