บทที่ 15: Image Handling & Export
สารบัญเนื้อหา
บทที่ 15: Image Handling & Export
🎯 เป้าหมายการเรียนรู้
หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:
- แปลง Widget ให้กลายเป็นรูปภาพ (PNG) ด้วย
RepaintBoundary - บันทึกรูปภาพลง Gallery ของเครื่อง (ใช้
gal) - แชร์รูปภาพไปยังแอปอื่น (Line, Facebook, Story) ด้วย
share_plus - เลือกรูปจาก Gallery มาแสดงในแอป (ใช้
image_picker)
📚 ทฤษฎีและแนวคิด
1. Widget to Image (การแปลง Widget เป็นรูป)
ใน Flutter ทุกอย่างคือ Widget แต่เราจะ "ถ่ายรูป" Widget นั้นออกมาได้อย่างไร? คำตอบคือ RepaintBoundary:
- มันเหมือน "กรอบรูป" ที่เราเอาไปครอบ Widget ที่ต้องการ
- เราสามารถสั่งให้กรอบรูปนี้ "Render" ภาพในกรอบออกมาเป็นข้อมูล Binary (Uint8List) ได้
2. Sandbox System (ข้อจำกัดเรื่องไฟล์)
- iOS/Android: แอปไม่สามารถเขียนไฟล์ลง Gallery ตรงๆ ได้ตามใจชอบ ต้องขอ Permission
- Temporary Directory: เวลาเราจะแชร์ไฟล์ เรามักจะบันทึกไฟล์ชั่วคราวใน Cache ของแอปก่อน แล้วค่อยส่ง Path ให้แอปอื่น
💻 การเขียนโค้ด (Implementation)
1. ติดตั้ง Package
dependencies:
# สำหรับจับภาพและตกแต่ง
flutter:
sdk: flutter
# สำหรับบันทึกลงเครื่อง
gal: ^2.3.0
# สำหรับแชร์
share_plus: ^9.0.0
path_provider: ^2.1.2 # หาที่เก็บไฟล์ชั่วคราว
# สำหรับเลือกรูป
image_picker: ^1.0.7
2. สร้าง Utility Class (ตัวช่วย)
ไฟล์: mobile/lib/utils/file_saver.dart
เราจะสร้าง Class ที่รวมฟังก์ชันเกี่ยวกับไฟล์ไว้ที่เดียว
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:gal/gal.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class FileSaver {
อธิบายทีละบรรทัด (Imports):
-
import 'dart:ui' as ui- นำเข้า low-level painting library ตั้งชื่อเล่นว่า
ui(เพื่อไม่ให้ชนกับ classes อื่น)
- นำเข้า low-level painting library ตั้งชื่อเล่นว่า
-
import 'package:gal/gal.dart'- Library สำหรับบันทึกรูปลง Gallery (Gallery Abstraction Layer)
-
import 'package:share_plus/share_plus.dart'- Library สำหรับเรียก Share Sheet ของเครื่อง (ส่งรูปเข้า Line/Facebook)
อธิบาย: // --- 1. Capture Widget as Image --- // รับ GlobalKey ของ RepaintBoundary -> คืนค่าเป็น bytes (PNG) static Future<Uint8List?> capturePng(GlobalKey key) async { try { // ค้นหา RenderObject จาก Key final boundary = key.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return null;
// แปลงเป็น Image (pixelRatio 3.0 = ชัดกว่าจอปกติ 3 เท่า)
final image = await boundary.toImage(pixelRatio: 3.0);
// แปลง Image เป็น ByteData (PNG)
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
} catch (e) {
print("Errot capturing png: $e");
return null;
}
}
// --- 2. Save to Gallery --- static Future<void> saveToGallery(Uint8List bytes) async { // ต้องขอ Permission ก่อน (gal ทำให้เองในตัว) // แต่เพื่อความชัวร์ ควรเช็คก่อน if (!await Gal.hasAccess()) { await Gal.requestAccess(); }
// บันทึกไฟล์ชั่วคราว (เพราะ Gal รับเป็น path)
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/quote_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(bytes);
// บันทึกลง Gallery
await Gal.putImage(file.path, album: 'QUOM');
}
// --- 3. Share File --- static Future<void> shareImage(Uint8List bytes) async { final tempDir = await getTemporaryDirectory(); final file = File('${tempDir.path}/share_quote.png'); await file.writeAsBytes(bytes);
// ใช้ XFile (Format กลางของ Flutter)
await Share.shareXFiles(
[XFile(file.path)],
text: 'ข้อคิดดีๆ จากแอพ QUOM',
);
} }
### 3. หน้าจอ Export Screen
ไฟล์: `mobile/lib/screens/export_screen.dart`
หน้านี้คือหัวใจสำคัญที่เราจะให้ user แต่งรูปก่อนเซฟ
```dart
class ExportScreen extends StatefulWidget {
final Note note; // รับข้อมูล Note มาแสดง
const ExportScreen({required this.note, super.key});
@override
State<ExportScreen> createState() => _ExportScreenState();
}
class _ExportScreenState extends State<ExportScreen> {
// Key สำคัญสำหรับทำ RepaintBoundary
final GlobalKey _globalKey = GlobalKey();
// State สำหรับปรับแต่ง
Color _bgColor = Colors.white;
bool _showCredit = true;
// ฟังก์ชันกดปุ่มเซฟ
Future<void> _saveImage() async {
// 1. หมุนติ้วๆ
// 2. Capture
final bytes = await FileSaver.capturePng(_globalKey);
if (bytes != null) {
// 3. Save
await FileSaver.saveToGallery(bytes);
// 4. แจ้งเตือนสำเร็จ
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("บันทึกรูปเรียบร้อยแล้ว!")),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("แชร์คำคม")),
body: Column(
children: [
// ส่วนแสดงผลรูปภาพที่จะเซฟ
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
// --- หัวใจสำคัญ: RepaintBoundary ---
child: RepaintBoundary(
key: _globalKey, // ผูก Key ไว้
child: Container(
// ตกแต่ง Container ให้เหมือน Card สวยๆ
padding: EdgeInsets.all(32),
decoration: BoxDecoration(
color: _bgColor,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 20,
offset: Offset(0, 10)
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min, // ขนาดพอดีเนื้อหา
children: [
// Quote Text
Text(
'"${widget.note.quote}"',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
fontFamily: 'Sarabun', // ฟอนต์ไทยสวยๆ
),
),
SizedBox(height: 20),
Divider(),
// Author / Credit
if (_showCredit)
Text(
"จากแอป QUOM",
style: TextStyle(color: Colors.grey),
),
],
),
),
),
),
),
),
// ส่วนเครื่องมือปรับแต่ง (Toolbar)
Container(
padding: EdgeInsets.all(16),
color: Colors.white,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: Icon(Icons.color_lens, color: Colors.blue),
onPressed: () => setState(() => _bgColor = Colors.blue[50]!),
),
IconButton(
icon: Icon(Icons.share),
onPressed: () async {
final bytes = await FileSaver.capturePng(_globalKey);
if (bytes != null) await FileSaver.shareImage(bytes);
},
),
ElevatedButton.icon(
icon: Icon(Icons.save_alt),
label: Text("บันทึก"),
onPressed: _saveImage,
),
],
),
),
],
),
);
}
}
4. Custom Painter (วาดลวดลายเอง)
บางทีเราอยากได้พื้นหลังลายจุด ลายตาราง ที่ไม่มีรูปให้โหลด เราสามารถ "วาดเอง" ได้ด้วย code
class PatternPainter extends CustomPainter {
final Color color;
PatternPainter({this.color = Colors.grey});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color.withOpacity(0.1)
..strokeWidth = 1;
// วาดลายเส้นทแยงมุม
for (double i = 0; i < size.width + size.height; i += 20) {
canvas.drawLine(
Offset(0, i),
Offset(i, 0),
paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
การใช้งาน:
ใส่ CustomPaint เป็น background ใน Stack
Stack(
children: [
CustomPaint(
size: Size.infinite,
painter: PatternPainter(),
),
// ... เนื้อหาอื่นๆ
],
)
📝 แบบฝึกหัด
โจทย์ที่ 1: เปลี่ยนฟอนต์ (Font Selection)
สิ่งที่ต้องทำ:
- เพิ่มตัวแปร
String _fontFamily - เพิ่มปุ่มเลือกฟอนต์ (Sarabun, Kanit, Prompt)
- เปลี่ยน
TextStyleตามฟอนต์ที่เลือก
โจทย์ที่ 2: Profile Image Overlay
สิ่งที่ต้องทำ:
- ใช้
ImagePickerให้ user เลือกรูปตัวเอง - แสดงรูปตัวเองเป็นวงกลมเล็กๆ มุมขวาล่างของ Card (ใน
RepaintBoundary)
โจทย์ที่ 3: Gradient Background
สิ่งที่ต้องทำ:
- เปลี่ยน
_bgColorจากColorเดียว เป็นGradient - สร้างปุ่มเลือก Gradient Presets สวยๆ 3-4 แบบ
📌 สรุป
การทำ Export Image ให้ User รู้สึกเหมือนเราเป็นแอปแต่งรูปย่อมๆ:
- RepaintBoundary: คือหัวใจในการแปลง UI เป็นรูป
- Customization: ยิ่งให้ user ปรับแต่งได้มาก ยิ่งดี (สี, ฟอนต์, พื้นหลัง)
- Share: การแชร์ที่ง่าย (กดแล้วเด้งไป Line/IG) ช่วยให้แอปเราเป็นที่รู้จักมากขึ้น
ไฟล์ที่เกี่ยวข้อง
🔜 บทถัดไป
บทที่ 16: Multi-language (i18n) แอปเราดีขนาดนี้ คนต่างชาติต้องได้ใช้! มาทำระบบเปลี่ยนภาษา (ไทย/อังกฤษ) กันเถอะ