บทที่ 21advanced|8 KB

บทที่ 21: Dynamic Theme System

บทที่ 21: Dynamic Theme System

🎯 เป้าหมายการเรียนรู้

หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:

  • สร้างระบบ Theme ที่มีความยืดหยุ่นสูง (ไม่ใช่แค่ Light/Dark)
  • ใช้ ThemeExtension เพื่อเพิ่มสีพิเศษที่ Material Design ไม่มีให้
  • สร้าง Animation ตอนเปลี่ยน Theme (Smooth Transition)
  • จัดการ Theme แยกตาม User Preference

📚 ทฤษฎีและแนวคิด

1. ทำไมต้อง Dynamic Theme?

ผู้ใช้แต่ละคนมีความชอบไม่เหมือนกัน บางคนชอบสีมืด บางคนชอบสีสว่าง หรือบางแอปอาจจะมี "เทศกาล" (Christmas Theme, Halloween Theme) การเขียนโค้ดแบบ Hard-code สี จะทำให้แก้ยากมาก

2. ThemeData vs ThemeExtension

  • ThemeData: คือกล่องมาตรฐานที่ Flutter ให้มา (มี primary, secondary, background ฯลฯ)
  • ThemeExtension: คือ "ช่องเสริม" ที่เราเจาะเพิ่มเองได้ เช่น ถ้าเราอยากได้สี success, warning, info ซึ่งไม่มีในมาตรฐาน MD3 เราต้องใช้ตัวนี้

💻 การเขียนโค้ด (Implementation)

1. สร้าง Theme Extension

ไฟล์: mobile/lib/theme/app_colors.dart

สมมติเราอยากได้สีตระกูล "Vivid" (สดใส) ที่ใช้ทั่วแอป

import 'package:flutter/material.dart';

**อธิบายทีละบรรทัด (Imports):**

- `import 'package:flutter/material.dart'`
  - ใช้สำหรับคลาส `ThemeExtension` และ `Color`

**อธิบาย:**

@immutable
class AppColors extends ThemeExtension<AppColors> {
  final Color vividGreen;
  final Color vividYellow;
  final Color vividRed;

  const AppColors({
    required this.vividGreen,
    required this.vividYellow,
    required this.vividRed,
  });

  // สร้างค่า Default (Light)
  factory AppColors.light() {
    return const AppColors(
      vividGreen: Color(0xFF00C853),
      vividYellow: Color(0xFFFFD600),
      vividRed: Color(0xFFFF1744),
    );
  }

  // สร้างค่า Default (Dark) - อาจจะปรับให้หม่นลงนิดนึง
  factory AppColors.dark() {
    return const AppColors(
      vividGreen: Color(0xFF00E676), // สว่างขึ้นนิดหน่อยบนพื้นดำ
      vividYellow: Color(0xFFFFEA00),
      vividRed: Color(0xFFFF5252),
    );
  }

  @override
  ThemeExtension<AppColors> copyWith({
    Color? vividGreen,
    Color? vividYellow,
    Color? vividRed,
  }) {
    return AppColors(
      vividGreen: vividGreen ?? this.vividGreen,
      vividYellow: vividYellow ?? this.vividYellow,
      vividRed: vividRed ?? this.vividRed,
    );
  }

  @override
  ThemeExtension<AppColors> lerp(ThemeExtension<AppColors>? other, double t) {
    if (other is! AppColors) return this;
    
    // Feature เด็ด: Flutter จะคำนวณการไล่สีให้เองตอนเปลี่ยน Theme (Animation)
    return AppColors(
      vividGreen: Color.lerp(vividGreen, other.vividGreen, t)!,
      vividYellow: Color.lerp(vividYellow, other.vividYellow, t)!,
      vividRed: Color.lerp(vividRed, other.vividRed, t)!,
    );
  }
}

2. ผูกกับ ThemeData หลัก

ไฟล์: mobile/lib/theme/app_theme.dart

import 'app_colors.dart';

class AppTheme {
  static ThemeData light() {
    return ThemeData(
      useMaterial3: true,
      brightness: Brightness.light,
      // ... การตั้งค่าอื่นๆ ...
      
      // ใส่ Extension เข้าไป
      extensions: [
        AppColors.light(),
      ],
    );
  }

  static ThemeData dark() {
    return ThemeData(
      // ...
      extensions: [
        AppColors.dark(),
      ],
    );
  }
}

3. การเรียกใช้งานใน Widget

เวลาจะใช้สี เราจะไม่เรียก Colors.green ตรงๆ แต่จะเรียกผ่าน Theme

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ดึง Extension ออกมา
    final appColors = Theme.of(context).extension<AppColors>()!;

    return Container(
      color: appColors.vividGreen, // สีจะเปลี่ยนเองตาม Theme
      child: Text("Hello Dynamic World"),
    );
  }
}

4. Animated Theme Switcher

ไฟล์: mobile/lib/widgets/theme_switcher_button.dart

ปุ่มเปลี่ยน Theme สวยๆ พร้อม Animation

class ThemeSwitcherButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<ThemeMode>(
      valueListenable: ThemeController.instance.themeMode,
      builder: (context, mode, child) {
        
        // Icon จะเปลี่ยนตาม Mode
        IconData icon;
        if (mode == ThemeMode.light) icon = Icons.wb_sunny;
        else if (mode == ThemeMode.dark) icon = Icons.nights_stay;
        else icon = Icons.brightness_auto;

        return IconButton(
          icon: AnimatedSwitcher(
            duration: Duration(milliseconds: 300),
            transitionBuilder: (child, anim) => RotationTransition(
              turns: anim, 
              child: child
            ),
            child: Icon(icon, key: ValueKey(mode)),
          ),
          onPressed: () {
            // Logic วน Loop: Light -> Dark -> System
            if (mode == ThemeMode.light) {
              ThemeController.instance.setTheme(ThemeMode.dark);
            } else if (mode == ThemeMode.dark) {
              ThemeController.instance.setTheme(ThemeMode.system);
            } else {
              ThemeController.instance.setTheme(ThemeMode.light);
            }
          },
        );
      },
    );
  }
}

📝 แบบฝึกหัด

โจทย์ที่ 1: Custom Font Scale

สิ่งที่ต้องทำ:

  1. สร้าง AppTypography Extension
  2. เก็บค่า scaleFactor (เช่น 1.0, 1.2, 0.8)
  3. ในหน้า UI ให้อ่านค่านี้ไปคูณกับขนาดฟอนต์ปกติ (User ปรับตัวหนังสือใหญ่เล็กได้)

โจทย์ที่ 2: Theme Presets (Color Palettes)

สิ่งที่ต้องทำ:

  1. สร้างธีมสีเพิ่ม: "Ocean" (ฟ้า), "Forest" (เขียว), "Sunset" (ส้ม)
  2. มีหน้าจอให้ User เลือก Theme สีที่ชอบ
  3. (ต้องแก้ ThemeController ให้เก็บค่าสีด้วย ไม่ใช่แค่ Light/Dark)

📌 สรุป

Dynamic Theme ช่วยให้แอปดูมีชีวิตชีวาและใส่ใจผู้ใช้:

  • Extensions: ช่วยให้เราหลุดกรอบจาก Material Design เดิมๆ
  • Lerp (Linear Interpolation): คือเวทมนตร์ที่ทำให้การเปลี่ยนสีนุ่มนวล
  • Separation of Concerns: แยก Logic สีออกจาก Logic หน้าจออย่างชัดเจน

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 22: Performance Optimization บทสุดท้าย! มาดูวิธีการทำให้แอปลื่นหัวแตก ไม่กระตุก ไม่กินแบต