ข้ามไปยังเนื้อหา

โครง navigation: แถบเมนู หน้า และการสลับหน้า

  1. สร้างเมนูนำทางด้วย lv_menu ที่สร้างทุกหน้าไว้ครั้งเดียว แล้วสลับหน้าที่แสดงตามเมนูที่เลือก
  2. แยก layout, navigation logic และหน้าแต่ละหน้าออกจากกันตามโครงไฟล์ของ episode
  3. เพิ่มหน้าใหม่หนึ่งหน้าเข้าเมนูโดยไม่แก้หน้าที่มีอยู่

โค้ดจริงใช้ 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 ต้นทาง

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 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() ฟังก์ชันเดียวกัน — ปลายทางเดียวกัน สอง ทางเข้า

เพราะมีทางเข้าสองทาง (top nav + sidebar) ทุกครั้งที่เปลี่ยนหน้า menu_nav_apply_active_state() ต้องอัปเดต style ของ ทั้งปุ่ม top nav และ sidebar link พร้อมกัน โดยเทียบกับ state->current_page — ถ้าอัปเดตแค่ฝั่งใดฝั่งหนึ่ง ผู้ใช้ที่สลับหน้าจาก sidebar จะเห็นปุ่ม top nav highlight หน้าเดิมค้างอยู่ (หรือกลับกัน)

จุดที่ละเอียดที่สุดของ 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 เสร็จก่อนแล้วค่อย สลับหน้า

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);
  • เชื่อคำบรรยายในส่วน 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) — ต้องอัปเดตทั้งสองฝั่งพร้อมกันทุกครั้งเพราะมี ทางเข้าสองทางไปหน้าเดียวกัน
Terminal window
# ในโฟลเดอร์ master template (ดูบทเรียน 1.1)
# 1) ลบไฟล์ของ episode เก่าใน proj_cm55/apps/
# 2) คัดลอกไฟล์ทั้งหมดของ episode นี้ลงใน proj_cm55/apps/
make build
make program # flash ผ่าน KitProg3

หรือเปิด ตัวอย่างนี้บน Developer Hub แล้ว flash เฟิร์มแวร์สำเร็จรูป

หน้าจอของ EP04 — Menu Navigation บน TESAIoT Dev Kit

ก่อนอ่านโค้ด ให้ทายว่าหน้าจอนี้มี object อะไรบ้าง และอะไรเปลี่ยนเมื่อผู้ใช้แตะหรือเมื่อค่าเซนเซอร์เปลี่ยน

  1. ทาย ก่อนแก้: เลือกค่าหนึ่งค่าที่ README ของตัวอย่างอธิบายไว้ในส่วน How แล้วเขียนว่าจะเห็นอะไรเปลี่ยนบนจอหรือใน log
  2. แก้และรัน build + flash แล้วเทียบกับที่ทายไว้ ถ้าไม่ตรง ให้หาว่าเข้าใจส่วนไหนผิด
  3. ทำเพิ่ม ต่อยอดหนึ่งอย่างที่ตัวอย่างยังไม่มี แล้วเก็บภาพหรือวิดีโอไว้ใน portfolio
  • lv_menu เก็บหน้าทั้งหมดไว้อย่างไร และการสร้างทุกหน้าไว้ครั้งเดียวมีข้อดีข้อเสียอะไร
  • ถ้าเพิ่มหน้าใหม่ ต้องแก้ไฟล์ใดบ้าง
  • ถ้าเปลี่ยนเป็นสร้างหน้าใหม่ทุกครั้งที่สลับเมนู โดยไม่ลบหน้าเก่า จะเกิดอะไรกับหน่วยความจำ

คำตอบอยู่ใน README ของตัวอย่างและในโค้ด ถ้าตอบข้อใดไม่ได้ ให้กลับไปอ่านส่วน Why / What / How อีกครั้ง

คำถามทบทวน

ลองตอบเองก่อน แล้วค่อยเปิดดูเฉลย

  1. ในโค้ดของ episode นี้ (commit 9a8e3ed) เมื่อแตะแท็บ WiFi เนื้อหากลางจอเปลี่ยนด้วยวิธีใด (เป้าหมายข้อ 1)

    1. ลบ child ทั้งหมดของ stage ด้วย lv_obj_clean() แล้วสร้างหน้าใหม่
    2. สร้างหน้าใหม่ทับหน้าเดิมโดยไม่ลบ
    3. หน้าทั้งสี่ถูกสร้างไว้ตั้งแต่เริ่มด้วย lv_menu_page_create() การแตะแท็บเพียงสั่ง lv_menu_set_page() ให้แสดงหน้าที่มีอยู่แล้ว
    4. ซ่อนหน้าอื่นด้วย 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 นี้ ให้ยึดโค้ดเป็นหลัก ข้อดีคือค่าที่ผู้ใช้กรอกในหน้าไม่หายเมื่อสลับไปมา ข้อเสียคือทุกหน้ากินหน่วยความจำตลอดเวลา

  2. ทำไม callback ของแท็บไม่เรียก lv_menu_set_page() ทันที แต่ส่งต่อผ่าน lv_async_call() (เป้าหมายข้อ 1)

    1. เพราะ lv_menu_set_page() ใช้ได้เฉพาะใน FreeRTOS task อื่น
    2. เพื่อเลื่อนการสลับหน้าไปทำหลัง event ปัจจุบันจบ (รอบ LVGL ถัดไป) หลังพับ sidebar แล้ว กันสถานะของ lv_menu ชนกัน ตามที่ comment ในโค้ดเขียนไว้
    3. เพื่อให้การสลับหน้ามี animation
    4. เพื่อให้สลับหน้าได้แม้ 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 เดิม แค่ย้ายไปรอบถัดไป

  3. ผู้ใช้แตะแท็บ WiFi แล้วแตะ Display ต่อทันที ก่อนที่ LVGL จะได้ประมวลผล async call สุดท้ายจะเห็นหน้าใด (เป้าหมายข้อ 1)

    1. WiFi เพราะคำสั่งแรกชนะ
    2. WiFi แล้วสลับเป็น Display (สลับสองครั้ง)
    3. Home เพราะคำสั่งที่ซ้อนกันถูกยกเลิก
    4. 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 ค่าล่าสุดแล้วสลับครั้งเดียว

  4. ทีมอยากเปลี่ยนสีแท็บที่ active ทั้งแถบบนและ sidebar โดยไม่แตะ logic การสลับหน้า ต้องแก้ที่ไหน (เป้าหมายข้อ 2)

    1. ค่าคงที่สีใน ui_menu_layout.h เช่น UI_MENU_NAV_ACTIVE_BG_HEX ซึ่งฟังก์ชันจัดสไตล์ใน menu_nav_logic.c อ่านไปใช้
    2. ใน callback ของแต่ละแท็บ
    3. ใน main.c ของ master template
    4. ใน 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 ไว้ที่เดียวทำให้เปลี่ยนธีมได้โดยไม่แตะลำดับการสลับหน้า

  5. ต้องการเพิ่มหน้า “Sensors” เป็นหน้าที่ห้า โดยไม่แก้เนื้อหาของหน้าที่มีอยู่ ต้องทำอะไรบ้าง (เลือกทุกข้อที่ถูก) (เป้าหมายข้อ 3)

    1. เพิ่มค่า MENU_NAV_PAGE_SENSORS ใน enum และเพิ่ม case ใน menu_nav_get_page_obj() กับ menu_nav_page_name()
    2. สร้างหน้าด้วย create_full_content_page(menu, …) แล้วเก็บ pointer ไว้ใน state
    3. เพิ่มปุ่มแท็บพร้อม callback ที่เรียก menu_nav_set_page(state, MENU_NAV_PAGE_SENSORS, true) และให้ menu_nav_apply_active_state() ไฮไลต์ปุ่มนี้ด้วย
    4. เรียก lv_obj_clean() กับหน้าเดิมทุกหน้าก่อนแสดงหน้า Sensors
    5. แก้ 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 ฉบับเต็ม

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · CC BY-NC 4.0

เนื้อหาเผยแพร่ภายใต้ CC BY-NC 4.0 นำไปใช้ต่อในงานที่ไม่ใช่เพื่อการค้าได้ โปรดอ้างอิงสมาคมสมองกลฝังตัวไทย (TESA) ทุกครั้ง · วิธีอ้างอิง TESA