[TIL] Day 73 Flutter 캐릭터 커스터마이징 기능

현서·2026년 3월 12일

[TIL] Flutter 9기

목록 보기
85/102

Flutter 캐릭터 커스터마이징 기능

1단계: 문제 분석

기존 시스템의 한계

  • 단일 이미지만 사용 → 머리, 옷 등 조합 불가
  • 색상 변경 시 → 전체 이미지를 다시 제작해야 함
  • 확장성 부족 → 새 파츠 추가 시 많은 이미지 필요

2단계: 핵심 학습

Layered Rendering (레이어 쌓기)

Stack 위젯으로 투명한 PNG를 순서대로 쌓아 캐릭터를 만든다.

피부 레이어 → 그림자 → 머리카락 → 의상 → 얼굴

Color Blending (색상 자연스럽게 입히기)

BlendMode.modulate 사용 → 원본 이미지의 질감을 유지하면서 색상만 변경

Image.asset(
  'body.png',
  color: skinColor,
  colorBlendMode: BlendMode.modulate,
)

3단계: 해결 전략

CharacterAvatar 위젯 만들기

렌더링 로직을 한 곳에 캡슐화하여 어디서든 재사용 가능하게 만든다.

class CharacterAvatar extends StatelessWidget {
  final String bodyPath;
  final String hairPath;
  final String facePath;
  final String clothPath;
  final Color skinColor;
  final Color hairColor;
  
  // 생성자 생략
  
  
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Image.asset(bodyPath, color: skinColor, colorBlendMode: BlendMode.modulate),
        Image.asset(hairPath, color: hairColor, colorBlendMode: BlendMode.modulate),
        Image.asset(clothPath),
        Image.asset(facePath),
      ],
    );
  }
}

4단계: 핵심 코드

커스터마이징 화면

class CharacterCustomization extends StatefulWidget {
  
  State<CharacterCustomization> createState() => _CharacterCustomizationState();
}

class _CharacterCustomizationState extends State<CharacterCustomization> {
  Color selectedSkinColor = const Color(0xFFFFDBBB);
  Color selectedHairColor = const Color(0xFF8B4513);
  String selectedHair = 'assets/parts/hair_short.png';
  String selectedCloth = 'assets/parts/cloth_shirt.png';

  void reset() {
    setState(() {
      selectedSkinColor = const Color(0xFFFFDBBB);
      selectedHairColor = const Color(0xFF8B4513);
      selectedHair = 'assets/parts/hair_short.png';
      selectedCloth = 'assets/parts/cloth_shirt.png';
    });
  }

  void save() {
    print('저장 완료');
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          // 미리보기
          CharacterAvatar(
            bodyPath: 'assets/parts/body.png',
            hairPath: selectedHair,
            facePath: 'assets/parts/face.png',
            clothPath: selectedCloth,
            skinColor: selectedSkinColor,
            hairColor: selectedHairColor,
          ),
          const SizedBox(height: 24),
          
          // 색상 선택
          Wrap(
            children: [
              _colorButton(const Color(0xFFFFDBBB)),
              _colorButton(const Color(0xFFEDB5A6)),
            ],
          ),
          
          // 머리 선택
          Wrap(
            children: [
              _hairButton('짧은 머리', 'assets/parts/hair_short.png'),
              _hairButton('긴 머리', 'assets/parts/hair_long.png'),
            ],
          ),
          
          const Spacer(),
          
          // 하단 버튼
          Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: reset,
                  child: const Text('초기화'),
                ),
              ),
              const SizedBox(width: 16),
              Expanded(
                child: ElevatedButton(
                  onPressed: save,
                  child: const Text('저장하기'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _colorButton(Color color) {
    return GestureDetector(
      onTap: () => setState(() => selectedSkinColor = color),
      child: Container(
        width: 50,
        height: 50,
        decoration: BoxDecoration(
          color: color,
          shape: BoxShape.circle,
          border: Border.all(
            color: selectedSkinColor == color ? Colors.black : Colors.grey,
          ),
        ),
      ),
    );
  }

  Widget _hairButton(String label, String path) {
    return GestureDetector(
      onTap: () => setState(() => selectedHair = path),
      child: Container(
        padding: const EdgeInsets.all(8),
        decoration: BoxDecoration(
          border: Border.all(
            color: selectedHair == path ? Colors.blue : Colors.grey,
          ),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(label),
      ),
    );
  }
}

5단계: 주의사항

에셋 정합성 중요

모든 파츠 이미지의 크기와 중심점이 일치해야 한다.

- 모든 파츠: 1000x1200px (동일)
- 중심점: 정확하게 맞춰야 레이어 쌓을 때 틀어지지 않음

성능 최적화

// 많은 레이어를 쓸 때는 RepaintBoundary로 감싸기
RepaintBoundary(
  child: CharacterAvatar(...),
)

디자인 철학: Premium & Vibrant

그림자 농도(0.25~0.3)와 색상 팔레트로 프리미엄한 느낌을 전달한다.


핵심: 유지보수 가능한 구조

CharacterAvatar라는 명확한 인터페이스를 만들면:

새 파츠 추가 시
→ UI 코드 수정 없음
→ 에셋 파일만 추가하면 됨

이로써 확장성과 재사용성을 동시에 확보했다.

0개의 댓글