โครง navigation: แถบเมนู หน้า และการสลับหน้า
เป้าหมาย
หัวข้อที่มีชื่อว่า “เป้าหมาย”- สร้างเมนูนำทางด้วย lv_menu ที่สร้างทุกหน้าไว้ครั้งเดียว แล้วสลับหน้าที่แสดงตามเมนูที่เลือก
- แยก layout, navigation logic และหน้าแต่ละหน้าออกจากกันตามโครงไฟล์ของ episode
- เพิ่มหน้าใหม่หนึ่งหน้าเข้าเมนูโดยไม่แก้หน้าที่มีอยู่
โค้ดจริงใช้ widget lv_menu ไม่ใช่ container สี่ชั้นตามที่ README ต้นทางอธิบาย
หัวข้อที่มีชื่อว่า “โค้ดจริงใช้ widget lv_menu ไม่ใช่ container สี่ชั้นตามที่ README ต้นทางอธิบาย”README ของ episode บน Developer Hub ส่วน How เล่าว่าโค้ดสร้าง header/nav/stage/footer เป็น container เปล่าสี่อัน
แล้ว lv_obj_clean() + rebuild เนื้อหาเองทุกครั้งที่สลับหน้า แต่โค้ดจริงที่ commit 9a8e3ed ใช้
lv_menu ซึ่งเป็น widget สำเร็จรูปของ LVGL สำหรับทำ multi-page navigation โดยเฉพาะ — สร้างทุกหน้าไว้ล่วงหน้า
ด้วย lv_menu_page_create(menu, title) แล้วสลับการแสดงผลด้วย lv_menu_set_page(menu, target_page) โดยไม่ต้องลบ
หรือสร้าง widget ใหม่เลยสักครั้ง บทเรียนนี้อธิบายตามโค้ดจริง ไม่ใช่ตามคำบรรยายในส่วน How ของ README ต้นทาง
โครงหน้าจอ: header คงที่ + top nav คงที่ + lv_menu ตรงกลาง + footer
หัวข้อที่มีชื่อว่า “โครงหน้าจอ: header คงที่ + top nav คงที่ + lv_menu ตรงกลาง + footer”ui_menu_navigation_create() จัดหน้าจอเป็นคอลัมน์แนวตั้งด้วย LV_FLEX_FLOW_COLUMN ประกอบด้วย header (ไอคอน
settings + หัวเรื่อง), top nav (ปุ่ม Home/WiFi/Display/Info/Back ที่ “แสดงตลอดเวลาเพื่อให้นำทางง่ายในชั้นเรียน”
ตามคอมเมนต์ในซอร์ส), พื้นที่ content ที่มี lv_menu วางอยู่ข้างใน และ footer status strip ตัว lv_menu เองถูก
ตั้งค่า lv_menu_set_mode_header(menu, LV_MENU_HEADER_TOP_FIXED) และ
lv_menu_set_mode_root_back_button(menu, LV_MENU_ROOT_BACK_BUTTON_DISABLED) แล้วซ่อน header ภายในของมันเองทั้งสอง
จุด (lv_menu_get_main_header(), lv_menu_get_sidebar_header()) ด้วย LV_OBJ_FLAG_HIDDEN เพราะ header กับ top nav
ที่เราวาดเองด้านนอกทำหน้าที่แทนไปแล้ว — ถ้าลืมซ่อน จะเห็น header ซ้อนกันสองชั้น
สองทางเข้าไปหน้าเดียวกัน: top nav กับ sidebar
หัวข้อที่มีชื่อว่า “สองทางเข้าไปหน้าเดียวกัน: top nav กับ sidebar”นอกจาก top nav ที่แสดงตลอดเวลาแล้ว โค้ดยังสร้าง sidebar page (“Navigate”) ที่ซ่อนอยู่ เปิด/ปิดได้ด้วยปุ่ม
settings icon ในมุมซ้ายของ header sidebar มีลิงก์ไปหน้าเดียวกันกับ top nav (WiFi/Display/Device) — ทั้งสองทางเข้า
เรียกคนละ callback (เช่น menu_nav_logic_wifi_btn_event_cb จาก top nav ปุ่ม WiFi และปุ่มเดียวกันจาก sidebar link
ก็เรียก callback ตัวเดียวกันนี้) แต่ทั้งคู่ไปจบที่ menu_nav_set_page() ฟังก์ชันเดียวกัน — ปลายทางเดียวกัน สอง
ทางเข้า
active state ต้องซิงก์สองที่พร้อมกัน
หัวข้อที่มีชื่อว่า “active state ต้องซิงก์สองที่พร้อมกัน”เพราะมีทางเข้าสองทาง (top nav + sidebar) ทุกครั้งที่เปลี่ยนหน้า menu_nav_apply_active_state() ต้องอัปเดต style
ของ ทั้งปุ่ม top nav และ sidebar link พร้อมกัน โดยเทียบกับ state->current_page — ถ้าอัปเดตแค่ฝั่งใดฝั่งหนึ่ง
ผู้ใช้ที่สลับหน้าจาก sidebar จะเห็นปุ่ม top nav highlight หน้าเดิมค้างอยู่ (หรือกลับกัน)
ทำไมต้องเลื่อนการสลับหน้าออกไปด้วย lv_async_call
หัวข้อที่มีชื่อว่า “ทำไมต้องเลื่อนการสลับหน้าออกไปด้วย lv_async_call”จุดที่ละเอียดที่สุดของ episode นี้คือ menu_nav_queue_page_switch() ไม่เรียก lv_menu_set_page() ทันทีในตัว
callback แต่เก็บ pending_page ไว้ก่อนแล้วเรียก lv_async_call(menu_nav_async_apply_pending_page, state) — คอมเมนต์
ในซอร์สระบุเหตุผลตรง ๆ ว่า “Always defer actual page switching to avoid lv_menu state race after sidebar
transitions” lv_async_call() คือการฝากฟังก์ชันไว้ให้ LVGL เรียกใน tick ถัดไปแทนที่จะรันทันทีตรงจุดนั้น เพราะปุ่มบน
top nav ทุกปุ่มเรียก menu_nav_set_page(state, page_id, true) ซึ่งอาจจะยุบ sidebar (collapse_sidebar = true)
ก่อนสลับหน้า — ถ้าสลับหน้าแบบ synchronous ทันทีในฟังก์ชันเดียวกับที่เพิ่งสั่งยุบ sidebar จะชนกับ layout state
ภายในของ lv_menu ที่ยังปรับตัวไม่เสร็จ การ defer ออกไปหนึ่ง tick จึงทำให้ sidebar transition เสร็จก่อนแล้วค่อย
สลับหน้า
footer status label ปรับเป็น printf-style ผ่าน lv_label_set_text_fmt
หัวข้อที่มีชื่อว่า “footer status label ปรับเป็น printf-style ผ่าน lv_label_set_text_fmt”menu_nav_update_status() เรียก lv_label_set_text_fmt(state->status_label, "Page: %s | Sidebar: %s", ...) ทุก
ครั้งหลังสลับหน้าหรือ toggle sidebar เพื่อให้ footer เป็นแหล่งเดียวที่บอกสถานะทั้งสองอย่างพร้อมกัน
ตัวอย่างสมบูรณ์
หัวข้อที่มีชื่อว่า “ตัวอย่างสมบูรณ์”โค้ดของ episode นี้อยู่ใน Developer Hub (อ้างอิงที่ commit 9a8e3ed) — อ่าน Why ของ README ต้นทาง เพื่อเข้าใจจุดประสงค์ของ episode แต่ โค้ดตัวอย่างด้านล่างคัดลอกจากไฟล์จริง เพื่อให้ตรงกับที่ build จริง (Apache-2.0, tesaiot/developer-hub, commit เดียวกัน)
nav/menu_nav_logic.c — เลื่อนการสลับหน้าออกไปด้วย lv_async_call:
static void menu_nav_async_apply_pending_page(void *user_data){ menu_nav_state_t *state = (menu_nav_state_t *)user_data;
if(state == NULL || !state->page_switch_pending) { return; }
state->page_switch_pending = false; menu_nav_apply_page_now(state, state->pending_page);}
static void menu_nav_queue_page_switch(menu_nav_state_t *state, menu_nav_page_id_t page_id){ if(state == NULL || state->menu == NULL) { return; }
state->pending_page = page_id;
if(!state->page_switch_pending) { state->page_switch_pending = true; lv_async_call(menu_nav_async_apply_pending_page, state); }}ซ่อน header ภายในของ lv_menu เองสองจุด เพราะ header/top nav ที่วาดเองทำหน้าที่แทนแล้ว:
lv_obj_t *main_header = lv_menu_get_main_header(state->menu);if(main_header != NULL) { lv_obj_add_flag(main_header, LV_OBJ_FLAG_HIDDEN);}
lv_obj_t *sidebar_header = lv_menu_get_sidebar_header(state->menu);if(sidebar_header != NULL) { lv_obj_add_flag(sidebar_header, LV_OBJ_FLAG_HIDDEN);}nav/ui_menu_navigation.c — สร้าง lv_menu และหน้าทั้งสี่ไว้ล่วงหน้า:
lv_obj_t *menu = lv_menu_create(content);lv_menu_set_mode_header(menu, LV_MENU_HEADER_TOP_FIXED);lv_menu_set_mode_root_back_button(menu, LV_MENU_ROOT_BACK_BUTTON_DISABLED);
lv_obj_t *home_page = create_full_content_page(menu, "Home", "Home", ...);lv_obj_t *page_wifi = create_full_content_page(menu, "WiFi Manager", "WiFi Manager", ...);lv_obj_t *page_display = create_full_content_page(menu, "Display Setting", "Display Setting", ...);lv_obj_t *page_device = create_full_content_page(menu, "Device Info", "Device Info", ...);
lv_menu_set_page(menu, home_page);main_example.c,nav/menu_nav_logic.hและnav/ui_menu_layout.h(layout constants ของทุก component) — ดูโฟลเดอร์เต็มที่hmi_ep04_menu_navigation/nav/
จุดที่มักพลาด
หัวข้อที่มีชื่อว่า “จุดที่มักพลาด”- เชื่อคำบรรยายในส่วน How ของ README ต้นทางทั้งหมด — README บอกว่าใช้ container header/nav/stage/footer แล้ว
lv_obj_clean()+ rebuild ตอนสลับหน้า แต่โค้ดจริงใช้lv_menuที่สร้างทุกหน้าไว้ล่วงหน้าแล้วสลับด้วยlv_menu_set_page()เท่านั้น ไม่มีการ clean/rebuild เลย — เวลาโค้ดกับคำบรรยายขัดกัน ให้ยึดโค้ดจริงเป็นหลัก - ลืมซ่อน header ภายในของ
lv_menu— ถ้าไม่เรียกlv_obj_add_flag(main_header, LV_OBJ_FLAG_HIDDEN)(และของ sidebar) จะเห็น header ซ้อนกันสองชั้นเพราะlv_menuมี header ของตัวเองมาด้วย - เรียก
lv_menu_set_page()ทันทีหลัง toggle sidebar โดยไม่ผ่านlv_async_call— จะชนกับ layout state ภายใน ของlv_menuที่ยังปรับตัวไม่เสร็จหลัง sidebar transition ทำให้พฤติกรรมไม่แน่นอน - อัปเดต active-state style แค่ฝั่งเดียว (top nav หรือ sidebar) — ต้องอัปเดตทั้งสองฝั่งพร้อมกันทุกครั้งเพราะมี ทางเข้าสองทางไปหน้าเดียวกัน
build และ flash
หัวข้อที่มีชื่อว่า “build และ flash”# ในโฟลเดอร์ master template (ดูบทเรียน 1.1)# 1) ลบไฟล์ของ episode เก่าใน proj_cm55/apps/# 2) คัดลอกไฟล์ทั้งหมดของ episode นี้ลงใน proj_cm55/apps/make buildmake program # flash ผ่าน KitProg3หรือเปิด ตัวอย่างนี้บน Developer Hub แล้ว flash เฟิร์มแวร์สำเร็จรูป
ดูของจริงก่อน
หัวข้อที่มีชื่อว่า “ดูของจริงก่อน”
ก่อนอ่านโค้ด ให้ทายว่าหน้าจอนี้มี object อะไรบ้าง และอะไรเปลี่ยนเมื่อผู้ใช้แตะหรือเมื่อค่าเซนเซอร์เปลี่ยน
- ทาย ก่อนแก้: เลือกค่าหนึ่งค่าที่ README ของตัวอย่างอธิบายไว้ในส่วน How แล้วเขียนว่าจะเห็นอะไรเปลี่ยนบนจอหรือใน log
- แก้และรัน build + flash แล้วเทียบกับที่ทายไว้ ถ้าไม่ตรง ให้หาว่าเข้าใจส่วนไหนผิด
- ทำเพิ่ม ต่อยอดหนึ่งอย่างที่ตัวอย่างยังไม่มี แล้วเก็บภาพหรือวิดีโอไว้ใน portfolio
เช็กความเข้าใจ
หัวข้อที่มีชื่อว่า “เช็กความเข้าใจ”- lv_menu เก็บหน้าทั้งหมดไว้อย่างไร และการสร้างทุกหน้าไว้ครั้งเดียวมีข้อดีข้อเสียอะไร
- ถ้าเพิ่มหน้าใหม่ ต้องแก้ไฟล์ใดบ้าง
- ถ้าเปลี่ยนเป็นสร้างหน้าใหม่ทุกครั้งที่สลับเมนู โดยไม่ลบหน้าเก่า จะเกิดอะไรกับหน่วยความจำ
คำตอบอยู่ใน README ของตัวอย่างและในโค้ด ถ้าตอบข้อใดไม่ได้ ให้กลับไปอ่านส่วน Why / What / How อีกครั้ง
แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- README ของ episode · โฟลเดอร์โค้ด · commit
9a8e3ed - เปิดตัวอย่างนี้บน Developer Hub
- โค้ดเป็นของ Developer Hub และอ้างอิงด้วยลิงก์ ไม่ได้คัดลอกเข้าคลังนี้
คำถามทบทวน
ลองตอบเองก่อน แล้วค่อยเปิดดูเฉลย
-
ในโค้ดของ episode นี้ (commit 9a8e3ed) เมื่อแตะแท็บ WiFi เนื้อหากลางจอเปลี่ยนด้วยวิธีใด (เป้าหมายข้อ 1)
- ลบ child ทั้งหมดของ stage ด้วย lv_obj_clean() แล้วสร้างหน้าใหม่
- สร้างหน้าใหม่ทับหน้าเดิมโดยไม่ลบ
- หน้าทั้งสี่ถูกสร้างไว้ตั้งแต่เริ่มด้วย lv_menu_page_create() การแตะแท็บเพียงสั่ง lv_menu_set_page() ให้แสดงหน้าที่มีอยู่แล้ว
- ซ่อนหน้าอื่นด้วย LV_OBJ_FLAG_HIDDEN ทีละหน้าใน callback ของปุ่ม
ดูเฉลย
คำตอบ: C. หน้าทั้งสี่ถูกสร้างไว้ตั้งแต่เริ่มด้วย lv_menu_page_create() การแตะแท็บเพียงสั่ง lv_menu_set_page() ให้แสดงหน้าที่มีอยู่แล้ว
ui_menu_navigation.c สร้าง home_page, page_wifi, page_display และ page_device ครั้งเดียว แล้ว menu_nav_apply_page_now() เรียก lv_menu_set_page() กับหน้าที่เลือก README ของ episode ยังบรรยายแบบ stage + lv_obj_clean() ซึ่งไม่ตรงกับโค้ดที่ commit นี้ ให้ยึดโค้ดเป็นหลัก ข้อดีคือค่าที่ผู้ใช้กรอกในหน้าไม่หายเมื่อสลับไปมา ข้อเสียคือทุกหน้ากินหน่วยความจำตลอดเวลา
-
ทำไม callback ของแท็บไม่เรียก lv_menu_set_page() ทันที แต่ส่งต่อผ่าน lv_async_call() (เป้าหมายข้อ 1)
- เพราะ lv_menu_set_page() ใช้ได้เฉพาะใน FreeRTOS task อื่น
- เพื่อเลื่อนการสลับหน้าไปทำหลัง event ปัจจุบันจบ (รอบ LVGL ถัดไป) หลังพับ sidebar แล้ว กันสถานะของ lv_menu ชนกัน ตามที่ comment ในโค้ดเขียนไว้
- เพื่อให้การสลับหน้ามี animation
- เพื่อให้สลับหน้าได้แม้ LVGL ยังไม่ถูก init
ดูเฉลย
คำตอบ: B. เพื่อเลื่อนการสลับหน้าไปทำหลัง event ปัจจุบันจบ (รอบ LVGL ถัดไป) หลังพับ sidebar แล้ว กันสถานะของ lv_menu ชนกัน ตามที่ comment ในโค้ดเขียนไว้
menu_nav_set_page() พับ sidebar ก่อน แล้ว menu_nav_queue_page_switch() เก็บ pending_page และเรียก lv_async_call() comment ระบุว่า Always defer actual page switching to avoid lv_menu state race after sidebar transitions งานที่ถูกเลื่อนยังทำใน LVGL context เดิม แค่ย้ายไปรอบถัดไป
-
ผู้ใช้แตะแท็บ WiFi แล้วแตะ Display ต่อทันที ก่อนที่ LVGL จะได้ประมวลผล async call สุดท้ายจะเห็นหน้าใด (เป้าหมายข้อ 1)
- WiFi เพราะคำสั่งแรกชนะ
- WiFi แล้วสลับเป็น Display (สลับสองครั้ง)
- Home เพราะคำสั่งที่ซ้อนกันถูกยกเลิก
- Display โดยสลับครั้งเดียว เพราะมี async call ค้างได้ตัวเดียว และ pending_page ถูกเขียนทับด้วยค่าล่าสุด
ดูเฉลย
คำตอบ: D. Display โดยสลับครั้งเดียว เพราะมี async call ค้างได้ตัวเดียว และ pending_page ถูกเขียนทับด้วยค่าล่าสุด
menu_nav_queue_page_switch() ตั้ง pending_page ทุกครั้ง แต่เรียก lv_async_call() เฉพาะเมื่อ page_switch_pending ยังเป็น false เมื่อ async call ทำงานจึงอ่าน pending_page ค่าล่าสุดแล้วสลับครั้งเดียว
-
ทีมอยากเปลี่ยนสีแท็บที่ active ทั้งแถบบนและ sidebar โดยไม่แตะ logic การสลับหน้า ต้องแก้ที่ไหน (เป้าหมายข้อ 2)
- ค่าคงที่สีใน ui_menu_layout.h เช่น UI_MENU_NAV_ACTIVE_BG_HEX ซึ่งฟังก์ชันจัดสไตล์ใน menu_nav_logic.c อ่านไปใช้
- ใน callback ของแต่ละแท็บ
- ใน main.c ของ master template
- ใน lv_conf.h
ดูเฉลย
คำตอบ: A. ค่าคงที่สีใน ui_menu_layout.h เช่น UI_MENU_NAV_ACTIVE_BG_HEX ซึ่งฟังก์ชันจัดสไตล์ใน menu_nav_logic.c อ่านไปใช้
menu_nav_set_top_tab_style() และ menu_nav_set_sidebar_link_style() อ่านสีจาก #define ใน ui_menu_layout.h การรวมค่าคงที่ของ layout ไว้ที่เดียวทำให้เปลี่ยนธีมได้โดยไม่แตะลำดับการสลับหน้า
-
ต้องการเพิ่มหน้า “Sensors” เป็นหน้าที่ห้า โดยไม่แก้เนื้อหาของหน้าที่มีอยู่ ต้องทำอะไรบ้าง (เลือกทุกข้อที่ถูก) (เป้าหมายข้อ 3)
- เพิ่มค่า MENU_NAV_PAGE_SENSORS ใน enum และเพิ่ม case ใน menu_nav_get_page_obj() กับ menu_nav_page_name()
- สร้างหน้าด้วย create_full_content_page(menu, …) แล้วเก็บ pointer ไว้ใน state
- เพิ่มปุ่มแท็บพร้อม callback ที่เรียก menu_nav_set_page(state, MENU_NAV_PAGE_SENSORS, true) และให้ menu_nav_apply_active_state() ไฮไลต์ปุ่มนี้ด้วย
- เรียก lv_obj_clean() กับหน้าเดิมทุกหน้าก่อนแสดงหน้า Sensors
- แก้ main.c ของ master template ให้รู้จักหน้าใหม่
ดูเฉลย
คำตอบ: A. เพิ่มค่า MENU_NAV_PAGE_SENSORS ใน enum และเพิ่ม case ใน menu_nav_get_page_obj() กับ menu_nav_page_name() · B. สร้างหน้าด้วย create_full_content_page(menu, …) แล้วเก็บ pointer ไว้ใน state · C. เพิ่มปุ่มแท็บพร้อม callback ที่เรียก menu_nav_set_page(state, MENU_NAV_PAGE_SENSORS, true) และให้ menu_nav_apply_active_state() ไฮไลต์ปุ่มนี้ด้วย
โครงของ episode ใช้ enum ของหน้าเป็นศูนย์กลาง หน้าใหม่ต้องมีค่า enum, object ของหน้า, ทางแมปจาก enum ไปยัง object และปุ่มที่สั่งสลับ โดยไม่ต้องแตะเนื้อหาหน้าอื่น lv_menu แสดงทีละหน้าอยู่แล้วจึงไม่ต้องลบหน้าเดิม และ main.c ของ master ไม่รู้จักหน้าใดของ episode
อ้างอิงบทเรียนนี้
ถ้านำบทเรียนนี้ไปสอน ทำสไลด์ หรือทำเอกสารต่อ ให้อ้างอิงด้วยข้อความนี้ ถ้าดัดแปลงเนื้อหา ให้เติม (ดัดแปลง)ต่อท้ายชื่อบทเรียน
"โครง navigation: แถบเมนู หน้า และการสลับหน้า" จาก TESA Open Knowledge โดยสมาคมสมองกลฝังตัวไทย (Thai Embedded Systems Association: TESA) https://github.com/tesaiot/tesa-qualification-program สัญญาอนุญาต CC BY-NC 4.0
ข้อความอ้างอิงภาษาอังกฤษ: "Navigation shell: menu bar, pages and page routing" from TESA Open Knowledge by the Thai Embedded Systems Association (TESA), https://github.com/tesaiot/tesa-qualification-program, licensed under CC BY-NC 4.0
ลิงก์บทเรียน: https://tesaiot.github.io/tesa-qualification-program/courses/tesaiot-firmware-stack/m02-hmi-menu-setting/l04-menu-navigation/
บทเรียนนี้ดัดแปลงจากต้นฉบับด้านล่าง เมื่ออ้างอิงให้คงเครดิตต้นฉบับไว้ด้วย
https://github.com/tesaiot/developer-hub/blob/9a8e3ed1d813bfd67fabf6b7ac15c6ff9750b465/hmi_ep04_menu_navigation · Code stays in the Developer Hub and is linked at pinned commits, never copied: the episodes, practice codes and main-branch examples are Apache-2.0; the master template and the OPTIGA client carry Infineon/Cypress EULAs.
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · CC BY-NC 4.0
เนื้อหาเผยแพร่ภายใต้ CC BY-NC 4.0 นำไปใช้ต่อในงานที่ไม่ใช่เพื่อการค้าได้ โปรดอ้างอิงสมาคมสมองกลฝังตัวไทย (TESA) ทุกครั้ง · วิธีอ้างอิง TESA