บทความนี้ต่อยอดจากตอนที่แล้ว ที่เราได้รู้จักกับการสร้าง qrcode
เพื่อใช้งาน แชร์ หรือส่งต่อไปยังแอปอื่นหรือบันทึกลงไว้ในเครื่องไปแล้ว
เนื้อหาตอนนี้เราจะมาสร้างส่วนที่เราใช้สแกนหรือการตรวจจับ qrcode
เพื่ออ่านค่า และนำค่าที่ได้ไปใช้งาน โดยเราจะใช้ plugin ที่ชื่อว่า
mobile_scanner ในการจัดการ
ทบทวนบทความตอนที่แล้วได้ที่
สร้างคิวอาร์โค้ดในแอป Flutter ด้วย qr flutter อย่างง่าย http://niik.in/1120
ติดตั้ง package ที่จำเป็นเพิ่มเติม ตามรายการด้านล่าง
แพ็กเก็จที่จำเป็นต้องติดตั้งเพิ่มเติม สำหรับการทำงานมีดังนี้
mobile_scanner: ^5.2.3 flutter_ringtone_player: ^4.0.0+3 url_launcher: ^6.3.0
สำหรับ flutter_ringtone_player และ url_launcher ที่เพิ่มเข้ามา ก็เพื่อให้เราสามารถจัดการ
บางอย่างได้มากขึ้น โดย flutter_ringtone_player เราจะใช้สำหรับเล่นไฟล์เสียงจากเครื่อง เพื่อให้
รู้ว่ามีการสแกนและส่งผลลัพธ์กลับมาแล้ว ทำให้เรารับรู้และสังเกตได้ง่าย ส่วน url_launcher เราจะ
ใช้สำหรับจัดการกับรูปแบบข้อความที่สแกนได้เพื่อใช้งานต่อ เช่น ถ้าเป็นลิ้งค์ หรือข้อความ sms หรือ
ค่าอื่นๆ ตามรูปแบบข้อมูล ก็สามารถส่งต่อไปใช้งานได้เลย แทนที่จะแสดงข้อความธรรมดา โดยตัว
url_launcher จะจัดการกับลิ้งค์หรือข้อความนั้นๆ แล้วส่งต่อไปจัดการได้
การกำหนดการขอสิทธิ์เข้าถึงการใช้งานข้อมูล
จริงๆ เนื้อหานี้เป็นบทความต่อเนื่อง อย่างไรก็ดี เมื่อมีการใช้กล้อง เราควรเพิ่มการขอสิทธิ์การ
ใช้งานกล้องเข้าไปด้วย ด้านล่างเป็นสิทธิ์ทั้งหมดในโค้ดตัวอย่าง ดาวน์โหลดท้ายบทความ
ไฟล์ android > app > src > main > AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/> <!-- For Android 13+ --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <application .... .... </manifest>
การสแกน Qr Code และ Barcode ด้วย mobile_scanner
ความสามารถของ mobile_scanner นอกจากจะสแกนคิวอาร์โค้ดแล้ว ยังรองรับการสแกน
Barcode ได้อีกด้วย อย่างไรก็ตามการใช้งาน plugin ตัวนี้จะมีทั้งรูปแบบที่รวม MLKit
Barcode-scanning for Android และแบบไม่รวม ซึ่งหากไม่ต้องการตั้งค่าอะไร จะเป็นแบบ
รวมเข้ามาแล้ว แต่ก็จะมีส่วนให้ขนาดไฟล์ของแอปเราเพิ่มขึ้น 3-10 MB โดยประมาณ ขึ้นกับการ
ใช้งาน ในที่นี้เราจะใช้แบบรวมมาแล้ว เพื่อให้รองรับกับ android หรือมือถือที่อาจจะไม่มี Google
Play ได้ และด้วยความสามารถที่น่าพอใจก็ดีพอที่จะใช้งานในรูปแบบนี้
ไฟล์ scanner.dart
import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:flutter_ringtone_player/flutter_ringtone_player.dart'; class Scanners extends StatefulWidget { static const routeName = '/scanner'; const Scanners({Key? key}) : super(key: key); @override State<StatefulWidget> createState() { return _ScannersState(); } } class _ScannersState extends State<Scanners> with WidgetsBindingObserver { // กำหนดส่วนควบคุม scanner ปรับค่าต่างๆ final MobileScannerController controller = MobileScannerController( autoStart: false, // ไม่เริ่มอัตโนมัติ torchEnabled: false, // ไม่เปิดแฟลชทันที useNewCameraSelector: true, // ใช้การเรียกใช้กล้องแบบ API ใหม่ ); // กำหนดค่าเก็บผลลัพธ์ ในที่นี้เราจะมีอยู่ 2 ค่า // _barcode ค่าที่ได้จากการสแกน และ _result ค่าไว้เก็บชั่วคราวไปส่งต่อ หรือปรับแต่ง Barcode? _result; Barcode? _barcode; // ตัวแปรตรวจจับข้อมูลที่มีการส่งมาอยางต่อเนื่อง เช่น ข้อมูลเซ็นเซอร์ หรือข้อมูลจากกล้อง StreamSubscription<Object?>? _subscription; // ตัวจัดการควบคุมข้อมูล String ที่ได้จากการสแกน TextEditingController _textController = TextEditingController(); // ฟังก์ชั่นจัดการข้อมูล barcode ถ้าไม่มีให้มีค่าเป็น null void _handleBarcode(BarcodeCapture barcodes) { if (mounted) { setState(() { _barcode = barcodes.barcodes.firstOrNull; }); } } @override void initState() { super.initState(); // ตรวจจับสถานะของแอป เมื่อใช้ร่วมกับ mixin WidgetsBindingObserver WidgetsBinding.instance.addObserver(this); // ตรวจจับค่า stream การเปลี่ยนแปลงข้อมูลที่ต่อเนื่อง _subscription = controller.barcodes.listen(_handleBarcode); // เรียกใช้ controller.start() โดยไม่ต้องรอให้ทำงานเสร็จ unawaited(controller.start()); } // ตรวจจับการเปลี่ยนแปลงของสถานะแอปต่างๆ แล้วกำหนดการทำงาน @override void didChangeAppLifecycleState(AppLifecycleState state) { if (!controller.value.isInitialized) { return; } switch (state) { case AppLifecycleState.detached: case AppLifecycleState.hidden: case AppLifecycleState.paused: return; // แอปกลับมาทำงานต่อ case AppLifecycleState.resumed: // ตรวจจับค่าข้อมูล _subscription = controller.barcodes.listen(_handleBarcode); unawaited(controller.start()); case AppLifecycleState.inactive: // เมื่อไม่ active หรือเปิดแอปอื่นขึ้นมาใช้งานอยู่ unawaited(_subscription?.cancel()); _subscription = null; unawaited(controller.stop()); } } // ฟังก์ชั่นสำหรับเปิดหรือเรียกใช้ค่าจาก รูปแบบข้อมูล Future<void> _launchURL(String url) async { try { // ตรวจสอบว่ามันเป็น URL หรือไม่ final Uri? parsedUrl = Uri.tryParse(url); if (parsedUrl != null && (parsedUrl.isAbsolute || url.startsWith('geo:'))) { // ถ้าเป็น URL ที่ถูกต้องหรือ geo link if (await canLaunchUrl(parsedUrl)) { await launchUrl(parsedUrl); } else { throw 'xdebug: Could not launch $url'; } } else { String finalUrl = url; // ถ้าไม่ใช่ URL ให้ใช้ canLaunchUrlString if (await canLaunchUrlString(finalUrl)) { await launchUrlString(finalUrl); } else { throw 'xdebug: Could not launch $url'; } } } catch (e) { print('Error launching URL: $e'); // สามารถแสดงข้อความแสดงข้อผิดพลาดให้ผู้ใช้ได้ที่นี่ เช่น: // ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error launching URL: $e'))); } } // การล้างค่าต่างๆ เมื่อไม่มีการใช้งาน @override Future<void> dispose() async { _textController.dispose(); WidgetsBinding.instance.removeObserver(this); unawaited(_subscription?.cancel()); _subscription = null; super.dispose(); await controller.dispose(); } // ฟังก์ชั่นสำหรับคัดลองข้อความที่สแกน Future<void> _copyToClipboard() async { await Clipboard.setData(ClipboardData(text: _textController.text)); // แสดงข้อความเมื่อคัดลอกข้อความแล้ว ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('คัดลอกข้อมูลเรียบร้อยแล้ว')), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('สแกน บาร์โค้ด / คิวอาร์โค้ด'), ), body: SingleChildScrollView( child: Column( // mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[ Container( height: MediaQuery.of(context).size.height * 0.60, // กำหนดความสูงที่ต้องการ child: _buildScanner(context), ), // Expanded(flex: 3, child: _buildScanner(context)), Container( child: Column( // mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: <Widget>[ // ส่วนของการแสดงข้อมูล เมื่อสแกนแล้ว ไม่เป็นค่า null if (_barcode != null) Builder(builder: (context) { // print("xdebug: ${_barcode!.rawValue!}"); // ถ้าผลลลัพธ์ชั่วคราวเป้น null หรือ ค่าเดิมไม่เหมือนค่าใหม่ที่สแกน if (_result == null || (_result!.displayValue! != _barcode!.displayValue!)) { // เก็บค่าชั่วคราว จากค่าที่เพิ่งสแกนมา _result = _barcode; // เล่นไฟล์เสียงแจ้งเตือนใช้ค่าเริ่มต้นของระบบ FlutterRingtonePlayer().playNotification(); } // กำหนดค่า string ข้อความที่สแกนมาได้ /* _textController.value = TextEditingValue(text: _barcode!.displayValue!); */ // กรณีเป็นค่าที่ไม่ใช่ข้อความ เช่น พวก vCard WIFI Event ใช้ค่า rawValue _textController.value = TextEditingValue(text: _barcode!.rawValue!); // กำหนดรูปแบบข้อมูลที่รองรับการเปิด้วย url_launcher // ในที่นี้จะไม่รองรับการเปิด WIFI VCard และ Event Calendar final canLaunchList = ['http:','https:','tel:','sms:', 'mailto:', 'geo:']; bool showLaunch = canLaunchList.any((field) => _textController.text.toString().contains(field)); return Padding( padding: const EdgeInsets.all(8.0), child: Container( width: 300.0, child: TextFormField( controller: _textController, decoration: InputDecoration( fillColor: Color(0xFFfeeeee), // สีพื้นหลัง filled: true, // กำหนดถ้ามีใส่สีพื้นหลัง fillColor prefixIcon: IconButton( onPressed: _copyToClipboard, // คัดลอกข้อความ icon: const FaIcon(FontAwesomeIcons.copy)), // ตรวจสอบรูปแบบข้อความ เพื่อกำหนดปุ่มด้านหลัง สามารถเรียกใช้งานหรือไม่ suffixIcon: showLaunch ? IconButton( onPressed: () { // ถ้าเป็นลิ้งค์ เรียกใช้งานฟังก์ชั่น _launchURL _launchURL(_textController.text); }, icon: Builder(builder: (context) { // ตรวจสอบว่าเป็นลิ้งค์ประเภทไหน เช่น ไลน์ อีเมล sms เบอร์โทร หรืออื่นๆ // แล้วกำหนดรูปแบบไอคอนให้สอดคล้อง if (_textController.text .toString() .indexOf(':\/\/line') > -1) { return const FaIcon( FontAwesomeIcons.line); } else if (_textController.text .toString() .indexOf('mailto:') > -1) { return const FaIcon( FontAwesomeIcons.envelope); } else if (_textController.text .toString() .indexOf('sms:') > -1) { return const FaIcon( FontAwesomeIcons.commentSms); } else if (_textController.text .toString() .indexOf('tel:') > -1) { return const FaIcon( FontAwesomeIcons.mobileScreenButton); } else if (_textController.text .toString() .indexOf('geo:') > -1) { return const FaIcon( FontAwesomeIcons.map); } else if (_textController.text .toString() .indexOf('youtu.be') > -1 || _textController.text .toString() .indexOf('youtube.com') > -1) { return const FaIcon( FontAwesomeIcons.youtube); } else if (_textController.text .toString() .indexOf('facebook.com') > -1 || _textController.text .toString() .indexOf('fb:\/\/') > -1) { return const FaIcon( FontAwesomeIcons.facebook); } else if (_textController.text .toString() .indexOf('tiktok.com') > -1) { return const FaIcon( FontAwesomeIcons.tiktok); } else { return const FaIcon(FontAwesomeIcons .upRightFromSquare); } }), ) : null, ), maxLines: null, // ทำให้ TextField ขยายได้ไม่จำกัด minLines: 1, // จำนวนแถวขั้นต่ำ readOnly: true, // ทำให้เป็นแบบอ่านอย่างเดียว ), ), ); }), // แสดงข้อความแนะนำ หรือคำอธิบายการใช้งาน Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Center( child: const Text( 'สแกนบาร์โค้ด หรือคิวอาร์โค้ด', style: TextStyle( fontSize: 20.0, color: Colors.black54, ), ), ), ], ), Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: <Widget>[ // ส่วนของการกำหนดการใช้งานแฟลช ValueListenableBuilder( valueListenable: controller, builder: (context, state, child) { // ถ้ากล้องไม่พร้อมใช้งานหรือยังไม่ทำงน แสดง // สร้าง widget กล่องที่มีขนาดเล็กที่สุดเท่าที่จะเป็นไปได้ ขนาด 0 // หรือ widget ที่ "ว่างเปล่า" if (!state.isInitialized || !state.isRunning) { return const SizedBox.shrink(); } // เงื่อนไขของการแสดงปุ่มเปิดแฟลช หรือปิดแฟลช หรือแบบ auto switch (state.torchState) { case TorchState.auto: return ElevatedButton( style: ElevatedButton.styleFrom( // backgroundColor: Color(0xFFf8a8ab), ), onPressed: () async { await controller.toggleTorch(); setState(() {}); }, child: const Icon( Icons.flash_auto, color: Color(0xFF404040), ), ); case TorchState.off: return ElevatedButton( style: ElevatedButton.styleFrom( // backgroundColor: Color(0xFFf8a8ab), ), onPressed: () async { await controller.toggleTorch(); setState(() {}); }, child: const Icon( Icons.flash_off, color: Color(0xFF404040), ), ); case TorchState.on: return ElevatedButton( style: ElevatedButton.styleFrom( // backgroundColor: Color(0xFFf8a8ab), ), onPressed: () async { await controller.toggleTorch(); setState(() {}); }, child: const Icon( Icons.flash_on, color: Color(0xFF404040), ), ); case TorchState.unavailable: return ElevatedButton( style: ElevatedButton.styleFrom( // backgroundColor: ui.Color.fromARGB(255, 226, 221, 222), ), onPressed: () async { setState(() {}); }, child: const Icon( Icons.no_flash, color: Colors.grey, ), ); } }, ), SizedBox( width: 10.0, ), // ส่วนของการกำหนดกล้องหน้า กล้องหลัง ถ้ามี ตรวจสอบจาก controller ValueListenableBuilder( valueListenable: controller, builder: (context, state, child) { // ถ้ากล้องไม่พร้อมใช้งานหรือยังไม่ทำงน แสดง // สร้าง widget กล่องที่มีขนาดเล็กที่สุดเท่าที่จะเป็นไปได้ ขนาด 0 // หรือ widget ที่ "ว่างเปล่า" if (!state.isInitialized || !state.isRunning) { return const SizedBox.shrink(); } // ตรวจสอบค่าสถานะจากกล้อง final int? availableCameras = state.availableCameras; // print("xdebug: ${availableCameras}"); // ตรวจสอบค่าสถานะจำนวนกล้องที่รองรับ // ถ้ามีแค่กล้องเดียว ไม่ต้องแสดงอะไร โขว์ widget ที่ "ว่างเปล่า" แทน // เพราะไม่จำเป็นต้องสลับกล้อง if (availableCameras != null && availableCameras < 2) { return const SizedBox.shrink(); } // กำหนด widget สำหรับไอคอนแสดงกล้อง final Widget icon; // กรณีมีมากกว่า 1 กล้อง // ใช้กล้องไหนอยู่แสดงไอคอนตามกล้องนั้น switch (state.cameraDirection) { case CameraFacing.front: icon = const Icon(Icons.camera_front, color: Color(0xFF404040)); case CameraFacing.back: icon = const Icon(Icons.camera_rear, color: Color(0xFF404040)); } // แสดงปุ่มสลับกล้อง return ElevatedButton( style: ElevatedButton.styleFrom( // backgroundColor: Color(0xFFf8a8ab), // elevation: 0.0, ), onPressed: () async { // เรียกใช้คำสั่งสลับกล้อง await controller.switchCamera(); setState(() {}); }, child: icon, ); }, ), SizedBox( width: 10.0, ), // ส่วนปุ่มใช้งาน และหยุดชั่วคราว ให้แสดงขึ้นกับค่า controller ValueListenableBuilder( valueListenable: controller, builder: (context, state, child) { // ถ้ากล้องหยุดชั่วคราวอยู่ หรือยังไม่ทำงาน แสดงปุ่มใช้งานต่อ if (!state.isInitialized || !state.isRunning) { return ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.white, ), onPressed: () async { // เรียรกใช้คำสั่งใช้งานกล้องต่อ await controller.start(); setState(() {}); }, child: const Text('ใช้งานต่อ', style: TextStyle( fontSize: 20, color: Color(0xFFf92D050))), ); } // ถ้ากล้องใช้งานอยู่ แสดงปุม หยุดชั่วคราว return ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.white, ), onPressed: () async { // เรียกใช้คำส่งหยุดกล้องชั่วคราว await controller.stop(); setState(() {}); }, child: const Text('หยุดชั่วคราว', style: TextStyle( fontSize: 20, color: Color(0xFFf92D050))), ); }, ), ], ), ], ), ), ], ), ), ); } // ฟังก์ชั่นสร้างส่วนของช่องสแกน Widget _buildScanner(BuildContext context) { // กำหนดความกว้าง ความสูง ตามต้องการ final double overlayHeight = 250.0; // Set custom height final double overlayWidth = 300.0; // Set custom width return Stack( fit: StackFit.expand, children: [ Positioned.fill( child: Container( color: Colors.black, // Background overlay ), ), // วางตำแหน่งของสแกนเนอร์ Center( child: Container( width: overlayWidth, height: overlayHeight, child: ClipRRect( borderRadius: BorderRadius.circular( 16.0), // กำหนดความมนของกรอบ child: MobileScanner( fit: BoxFit.cover, controller: controller, ), ), ), ), // ปรับแต่งพื้นที่สแกนเพิ่มเติม เช่นใส่เส้นขอบ Center( child: Container( width: overlayWidth, height: overlayHeight, decoration: BoxDecoration( border: Border.all( color: Color(0xFFf92D050), width: 3.0, ), borderRadius: BorderRadius.circular(16.0), ), ), ), ], ); } }
ผลลัพธ์ที่ได้
เราสามารถนำไปสแกนรูปแบบ qrcode จากเนื้อหาตอนที่แล้วทั้ง 10 รูปแบบได้ โดยในโค้ดตัวอย่าง
จะรองรับ ข้อความทั่วไป ลิ้งค์เว็บไซต์ทั่วไป sms เบอร์โทร อีเมล พิกัดในแผนที่ โดยจะแสดงทั้งข้อมูล
และสามารถกดลิ้งค์ด้านหลังเพื่อเปิดไปยังแอปที่เกี่ยวข้องได้ เช่น เบอร์โทร กดเข้าไปก็จะไปหน้าโทรให้
อีเมล ก็จะไปหน้าส่งอีเมลให้ แบบนี้เป็นต้น ส่วนอีก 3 รูปแบบที่เหลือ คือ WIFI VCard และ Calendar
Event จะไม่รองรับการเรียกใช้งานด้วย url_launcher ดังนั้น เราจึงแสดงแค่ปุ่มด้านหน้า ให้สามารถ
คัดลอกข้อความได้เท่านั้น ตัวสแกนเนอร์นี้ รองรับการสแกน Barcode ด้วย
เพิ่มเติมส่วนของ url_launcher
ในตัวอย่างจะเห็นว่าเรามีการเปิดไปยังแอปต่างๆ ที่เกี่ยวข้องกับรูปแบบข้อมูลของ qrcode ซึ่งในส่วนนี้
เราจำเป็นต้องกำหนดเพิ่มเติมในไฟล์ AndroidManifest.xml ส่วนของค่าการ <queries> หรือการ
บอกให้แอปนั้นสามารถรองรับการทำงานบางสิ่งบางอย่าง หรือการใช้งานร่วมกับแอปที่เกี่ยวข้องได้
ไฟล์ android > app > src > main > AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/> <!-- For Android 13+ --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <application android:label="Demo App" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> <activity android:name=".MainActivity" .......... </application> <queries> <intent> <action android:name="android.intent.action.PROCESS_TEXT"/> <data android:mimeType="text/plain"/> </intent> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="sms" /> </intent> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="tel" /> </intent> <intent> <action android:name="android.support.customtabs.action.CustomTabsService" /> </intent> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="geo" /> </intent> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="http" /> </intent> <intent> <action android:name="android.intent.action.VIEW" /> <data android:scheme="https" /> </intent> <intent> <action android:name="android.intent.action.SENDTO" /> <data android:scheme="mailto" /> </intent> </queries> </manifest>
การกำหนดค่าภายในแท็ก <queries> ในไฟล์ AndroidManifest.xml เป็นการแจ้งว่าแอป
ของเราต้องการค้นหาและสามารถทำงานร่วมกับแอปพลิเคชันอื่นๆ ที่สามารถตอบสนองต่อ intent
ที่กำหนดไว้ได้ โดยเฉพาะการทำงานร่วมกับข้อมูลหรือบริการต่างๆ ที่ระบุโดย scheme เช่น ข้อความ
(SMS), การโทร (tel), แผนที่ (geo), เว็บไซต์ (http, https), อีเมล (mailto) เป็นต้น
หวังว่าเนื้อหาตอนนี้จะเป็นประโยชน์ไม่มากก็น้อยในการนำปรับไปประยุกต์ใช้งานต่อไป สำหรับ
บทความในตอนหน้าจะเป็นอะไร รอติดตาม