09 พ.ค. 2568·อ่าน 2 นาที

รูปแบบ NavigationStack ใน SwiftUI สำหรับฟลูว์หลายขั้นตอนที่คาดเดาได้

รูปแบบ SwiftUI NavigationStack สำหรับฟลูว์หลายขั้นตอน พร้อมการกำหนดเส้นทางที่ชัดเจน พฤติกรรม Back ที่ปลอดภัย และตัวอย่างใช้งานจริงสำหรับ onboarding และ wizard การอนุมัติ

รูปแบบ NavigationStack ใน SwiftUI สำหรับฟลูว์หลายขั้นตอนที่คาดเดาได้

ปัญหาที่มักเกิดขึ้นในฟลูว์หลายขั้นตอน

ฟลูว์หลายขั้นตอนคือการเรียงลำดับที่ขั้นตอนที่ 1 ต้องเกิดขึ้นก่อนที่ขั้นตอนที่ 2 จะมีความหมาย ตัวอย่างทั่วไปได้แก่ onboarding, คำขออนุมัติ (ตรวจสอบ, ยืนยัน, ส่ง), และการกรอกแบบฟอร์มแบบวิซาร์ดที่ผู้ใช้สร้างร่างผ่านหลายหน้าจอ

ฟลูว์เหล่านี้ดูง่ายก็ต่อเมื่อปุ่ม Back ทำงานตามที่คนคาดหวัง หาก Back พาไปที่หน้าที่น่าแปลกใจ ผู้ใช้จะเลิกไว้วางใจแอป นำไปสู่การส่งข้อมูลผิดพลาด ทิ้ง onboarding และเกิดตั๋วซัพพอร์ตอย่าง “ฉันกลับไปที่หน้าที่ฉันอยู่ไม่ได้”

การนำทางที่ยุ่งเหยิงมักมีลักษณะหนึ่งในนี้:

  • แอปกระโดดไปหน้าผิด หรือออกจากฟลูว์เร็วเกินไป
  • หน้าจอเดียวกันโผล่สองครั้งเพราะถูก push ซ้ำ
  • ขั้นตอนรีเซ็ตเมื่อกด Back และผู้ใช้สูญเสียร่าง
  • ผู้ใช้ไปถึงขั้น 3 ได้โดยไม่ต้องทำขั้น 1 ให้เสร็จ ทำให้สถานะใช้ไม่ได้
  • หลัง deep link หรือรีสตาร์ทแอป แอปแสดงหน้าที่ถูกต้องแต่ข้อมูลผิด

โมเดลความคิดที่เป็นประโยชน์: ฟลูว์หลายขั้นตอนคือสองสิ่งที่เคลื่อนไหวร่วมกัน

อันดับแรก สแตกของหน้าจอ (สิ่งที่ผู้ใช้สามารถย้อนกลับผ่านได้) ที่สอง สเตตของฟลูว์ที่แชร์กัน (ข้อมูลร่างและความคืบหน้าที่ไม่ควรหายเพียงเพราะหน้าจอหายไป)

การตั้งค่าของ NavigationStack หลายแบบล้มเหลวเมื่อสแตกหน้าจอและสเตตของฟลูว์เลื่อนออกจากกัน ตัวอย่างเช่น onboarding อาจ push “สร้างโปรไฟล์” สองครั้ง (routes ซ้ำ) ในขณะที่ร่างโปรไฟล์อยู่ภายใน view และถูกสร้างขึ้นใหม่เมื่อ re-render ผู้ใช้กด Back เห็นเวอร์ชันแบบต่าง ๆ ของฟอร์ม และคิดว่าแอปไม่น่าเชื่อถือ

พฤติกรรมที่คาดเดาได้เริ่มจากการตั้งชื่อฟลูว์ กำหนดว่า Back ควรทำอะไรในแต่ละขั้นตอน และให้สเตตของฟลูว์มีที่อยู่ชัดเจนเพียงที่เดียว

พื้นฐานของ NavigationStack ที่คุณต้องรู้

สำหรับฟลูว์หลายขั้นตอน ให้ใช้ NavigationStack แทน NavigationView รุ่นเก่า NavigationView อาจทำงานต่างกันข้ามเวอร์ชัน iOS และยากที่จะเข้าใจเมื่อคุณ push, pop, หรือ restore หน้าจอ NavigationStack เป็น API สมัยใหม่ที่มองการนำทางเป็นสแตกจริง ๆ

NavigationStack เก็บประวัติที่ผู้ใช้ไปมา แต่ละการ push จะเพิ่ม destination ลงสแตก และแต่ละ action ของการกลับจะ pop หนึ่ง destination กฎเรียบง่ายนี้ทำให้ฟลูว์รู้สึกมั่นคง: UI ควรสะท้อนลำดับขั้นตอนที่ชัดเจน

สิ่งที่สแตกเก็บจริง ๆ

SwiftUI ไม่ได้เก็บออบเจ็กต์ view ของคุณ มันเก็บข้อมูลที่คุณใช้ในการนำทาง (ค่ารูท) และใช้ค่านั้นในการสร้าง destination view เมื่อจำเป็น นี่มีผลปฏิบัติหลายอย่าง:

  • อย่าไปพึ่งพาการที่ view จะยังมีชีวิตอยู่เพื่อเก็บข้อมูลสำคัญ
  • หากหน้าต้องการสเตต ให้เก็บไว้ในโมเดล (เช่น ObservableObject) ที่อยู่นอก view ที่ถูก push
  • หากคุณ push destination เดิมสองครั้งด้วยข้อมูลต่างกัน SwiftUI จะจัดให้เป็นรายการสแตกคนละรายการ

เมื่อฟลูว์ของคุณไม่ใช่แค่การ push หนึ่งหรือสองครั้ง NavigationPath คือสิ่งที่คุณควรใช้ คิดว่ามันเป็นรายการแก้ไขได้ของค่า “เราจะไปไหน” คุณสามารถ append routes เพื่อเดินหน้า, ลบรายการสุดท้ายเพื่อย้อนกลับ, หรือแทนที่ path ทั้งหมดเพื่อกระโดดไปยังขั้นตอนถัดไป

มันเหมาะเมื่อคุณต้องการขั้นตอนแบบวิซาร์ด ต้องรีเซ็ตฟลูว์หลังเสร็จงาน หรืออยากคืนค่าฟลูว์บางส่วนจากสถานะที่บันทึกไว้

ความคาดเดาได้สำคัญกว่าความฉลาด พยายามลดกฎซ่อนเร้น (การกระโดดอัตโนมัติ, การ pop เชิงนัย, side effect จาก view) จะลดบั๊กสแตก Back ที่แปลก ๆ ในภายหลัง

สร้างแบบจำลองฟลูว์ด้วย enum route ขนาดเล็ก

การนำทางที่คาดเดาได้เริ่มจากการตัดสินใจหนึ่งอย่าง: เก็บ routing ไว้ที่เดียว และทำให้แต่ละหน้าจอในฟลูว์เป็นค่าที่ชัดเจนขนาดเล็ก

สร้างแหล่งความจริงเดียว เช่น FlowRouter (เป็น ObservableObject) ที่เป็นเจ้าของ NavigationPath นั่นจะทำให้การ push และ pop ทุกครั้งสม่ำเสมอ แทนที่จะกระจายการนำทางไปยังหลาย view

โครงสร้าง router แบบเรียบง่าย

ใช้ enum เพื่อแทนขั้นตอน เพิ่ม associated values เฉพาะสำหรับตัวระบุที่น้ำหนักเบา (เช่น ID) ไม่ใช่โมเดลทั้งตัว

enum Step: Hashable {
    case welcome
    case profile
    case verifyCode(phoneID: UUID)
    case review(applicationID: UUID)
    case done
}

final class FlowRouter: ObservableObject {
    @Published var path = NavigationPath()

    func go(_ step: Step) { path.append(step) }
    func back() { if !path.isEmpty { path.removeLast() } }
    func reset() { path = NavigationPath() }
}

แยกสเตตของฟลูว์ออกจากสเตตการนำทาง

มองการนำทางเป็น “ผู้ใช้อยู่ที่ไหน” และสเตตของฟลูว์เป็น “สิ่งที่พวกเขากรอกไปแล้ว” วางข้อมูลฟลูว์ในสโตร์ของตัวเอง (เช่น OnboardingState ที่มีชื่อ, อีเมล, เอกสารที่อัปโหลด) และเก็บให้คงที่ขณะที่หน้าจอเข้ามาและหายไป

กฎง่าย ๆ:

  • FlowRouter.path เก็บเฉพาะค่า Step
  • OnboardingState เก็บข้อมูลอินพุตและร่างของผู้ใช้
  • Steps ถือ ID เพื่อดูข้อมูล ไม่ถือข้อมูลเต็มรูปแบบ

จะช่วยหลีกเลี่ยงการ hashing ที่เปราะบาง, path ขนาดใหญ่ และการรีเซ็ตที่น่าประหลาดใจเมื่อ SwiftUI สร้าง view ใหม่

ขั้นตอนทีละขั้น: สร้างวิซาร์ดด้วย NavigationPath

สำหรับหน้าจอแบบวิซาร์ด วิธีง่ายที่สุดคือควบคุมสแตกด้วยตัวคุณเอง ตั้งใจให้มีแหล่งความจริงเดียวสำหรับ “ตอนนี้เราอยู่ที่ไหนในฟลูว์?” และวิธีเดียวในการเดินหน้าหรือถอยหลัง

เริ่มด้วย NavigationStack(path:) ผูกกับ NavigationPath แต่ละหน้าจอที่ถูก push แทนด้วยค่า (บ่อยครั้งเป็น enum case) และคุณลงทะเบียน destinations เพียงครั้งเดียว

import SwiftUI

enum WizardRoute: Hashable {
    case profile
    case verifyEmail
    case permissions
    case review
}

struct OnboardingWizard: View {
    @State private var path = NavigationPath()
    @State private var currentIndex = 0

    private let steps: [WizardRoute] = [.profile, .verifyEmail, .permissions, .review]

    var body: some View {
        NavigationStack(path: $path) {
            StartScreen {
                goToStep(0) // push first step
            }
            .navigationDestination(for: WizardRoute.self) { route in
                switch route {
                case .profile:
                    ProfileStep(onNext: { goToStep(1) })
                case .verifyEmail:
                    VerifyEmailStep(onNext: { goToStep(2) })
                case .permissions:
                    PermissionsStep(onNext: { goToStep(3) })
                case .review:
                    ReviewStep(onEditProfile: { popToStep(0) })
                }
            }
        }
    }

    private func goToStep(_ index: Int) {
        currentIndex = index
        path.append(steps[index])
    }

    private func popToStep(_ index: Int) {
        let toRemove = max(0, currentIndex - index)
        if toRemove > 0 { path.removeLast(toRemove) }
        currentIndex = index
    }
}

เพื่อให้ Back คาดเดาได้ ให้ยึดนิสัยบางอย่าง Append เพียง route เดียวเพื่อเดินหน้า ให้ปุ่ม “ถัดไป” เป็นเส้นตรง (push เฉพาะขั้นตอนถัดไป) และเมื่อต้องกระโดดกลับ (เช่น “แก้ไขโปรไฟล์” จาก Review) ให้ตัดสแตกไปยังดัชนีที่รู้จัก

วิธีนี้จะหลีกเลี่ยงหน้าจอซ้ำโดยไม่ตั้งใจและทำให้ Back สอดคล้องกับที่ผู้ใช้คาดหวัง: แตะหนึ่งครั้ง = หนึ่งขั้นตอน

รักษาข้อมูลให้คงที่เมื่อหน้าจอเปลี่ยน

นำ pattern เหล่านี้ไปใช้งาน
นำรูปแบบที่เป็นประโยชน์เหล่านี้ไปใช้งานจริง โดยใช้แพลตฟอร์มแบบ no-code
ลอง AppMaster

ฟลูว์หลายขั้นตอนดูไม่น่าเชื่อถือเมื่อแต่ละหน้าจอเป็นเจ้าของสเตตของตัวเอง คุณพิมพ์ชื่อ เดินหน้า แล้วกดกลับ ฟิลด์ว่างเพราะ view ถูกสร้างขึ้นใหม่

วิธีแก้ง่าย ๆ: มองฟลูว์เป็นออบเจ็กต์ร่างเดียว แล้วให้แต่ละขั้นตอนแก้ไขมัน

ใน SwiftUI โดยทั่วไปหมายถึงการสร้าง ObservableObject ร่วมหนึ่งครั้งเมื่อเริ่มฟลูว์ แล้วส่งต่อให้แต่ละขั้นตอน ใช้ @StateObject หรือ @EnvironmentObject แทนการเก็บค่าร่างใน @State ของแต่ละ view เว้นแต่ว่าค่านั้นเป็นของหน้าจอนั้นจริง ๆ

final class OnboardingDraft: ObservableObject {
    @Published var fullName = ""
    @Published var email = ""
    @Published var wantsNotifications = false

    var canGoNextFromProfile: Bool {
        !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        && email.contains("@")
    }
}

สร้างที่จุดเริ่มต้น แล้วแชร์ด้วย @StateObject และ @EnvironmentObject (หรือส่งผ่านโดยตรง) ตอนนี้สแตกสามารถเปลี่ยนได้โดยไม่สูญเสียข้อมูล

ตัดสินใจว่าสิ่งใดควรอยู่รอดเมื่อกด Back

ไม่ใช่ทุกอย่างควรคงอยู่ตลอดไป กำหนดกฎล่วงหน้าเพื่อให้ฟลูว์สอดคล้อง

เก็บการป้อนของผู้ใช้ (ช่องข้อความ, สวิตช์, การเลือก) เว้นแต่ผู้ใช้จะรีเซ็ตเอง รีเซ็ตสเตต UI เฉพาะหน้า (เช่น loader ชั่วคราว, alert ชั่วคราว, แอนิเมชันสั้น ๆ) ล้างฟิลด์ที่ละเอียดอ่อน (เช่น รหัสครั้งเดียว) เมื่อออกจากขั้นตอนนั้น หากตัวเลือกเปลี่ยนขั้นตอนถัดไป ให้ล้างเฉพาะฟิลด์ที่ขึ้นกับการเลือกนั้น

การตรวจสอบความถูกต้อง (validation) เหมาะสมที่นี่ แทนที่จะปล่อยให้ผู้ใช้เดินหน้าแล้วแสดงข้อผิดพลาดในหน้าถัดไป ให้เก็บพวกเขาอยู่ที่ขั้นปัจจุบันจนกว่าจะ valid การปิดปุ่มตาม property เช่น canGoNextFromProfile มักเพียงพอ

บันทึกจุดเช็คแต่พอดี

ร่างบางอย่างอยู่ได้แค่หน่วยความจำ บางอย่างควรอยู่หลังรีสตาร์ทหรือคราฟต์แอป ค่าเริ่มต้นที่ใช้งานได้จริง:

  • เก็บข้อมูลในหน่วยความจำในขณะที่ผู้ใช้กำลังผ่านขั้นตอน
  • บันทึกลงเครื่องเมื่อถึง milestone ชัดเจน (สร้างบัญชี เสนออนุมัติ เริ่มชำระเงิน)
  • บันทึกก่อนหน้านั้นถ้าฟลูว์ยาวหรือการกรอกข้อมูลใช้เวลามากกว่าหนึ่งนาที

ด้วยวิธีนี้ หน้าจอสามารถขึ้นลงได้อย่างอิสระ และความก้าวหน้าของผู้ใช้ยังคงรู้สึกมั่นคงและเคารพเวลาของพวกเขา

Deep link สำคัญเพราะฟลูว์จริง ๆ ไม่ค่อยเริ่มที่ขั้น 1 คนกดอีเมล, push notification หรือแชร์ลิงก์และคาดว่าจะไปยังหน้าที่ถูกต้อง เช่น ขั้นตอนที่ 3 ของ onboarding หรือหน้าสุดท้ายของ approval

กับ NavigationStack ให้มอง deep link เป็นคำสั่งให้สร้าง path ที่ถูกต้อง ไม่ใช่คำสั่งให้กระโดดไปยัง view เดียว เริ่มจากจุดเริ่มต้นของฟลูว์แล้ว append เฉพาะขั้นตอนที่เป็นจริงสำหรับผู้ใช้นี้และ session นี้

เปลี่ยนลิงก์ภายนอกเป็นลำดับ route ที่ปลอดภัย

รูปแบบที่ดีคือ: แยก ID ภายนอก โหลดข้อมูลขั้นต่ำที่ต้องการ แล้วแปลงเป็นลำดับของ routes

enum Route: Hashable {
    case start
    case profile
    case verifyEmail
    case approve(requestID: String)
}

func pathForDeepLink(requestID: String, hasProfile: Bool, emailVerified: Bool) -> [Route] {
    var routes: [Route] = [.start]
    if !hasProfile { routes.append(.profile) }
    if !emailVerified { routes.append(.verifyEmail) }
    routes.append(.approve(requestID: requestID))
    return routes
}

การตรวจเหล่านี้คือราวการ์ด หากขาด prerequisites อย่าโยนผู้ใช้ไปที่ขั้น 3 พร้อมข้อผิดพลาดและไม่มีทางเดินหน้า ส่งพวกเขาไปที่ขั้นตอนแรกที่ขาดอยู่ และทำให้ back stack เล่าเรื่องที่สอดคล้อง

การกู้คืนฟลูว์ที่ทำค้างไว้

เพื่อกู้คืนหลังรีแลนช์ ให้บันทึกสองอย่าง: สถานะ route ล่าสุดที่รู้จัก และข้อมูลร่างที่ผู้ใช้กรอกไว้ จากนั้นตัดสินใจว่าจะกลับเข้ามาอย่างไรโดยไม่ทำให้ผู้ใช้แปลกใจ

ถ้าร่างยังสด (ไม่กี่นาทีหรือชั่วโมง) เสนอทางเลือก “Resume” ให้ชัดเจน ถ้าเก่า ให้เริ่มจากจุดเริ่มต้นแต่ใช้ร่างเติมฟิลด์ล่วงหน้า หากข้อกำหนดเปลี่ยน ให้สร้าง path ใหม่โดยใช้ guardrails เดิม

Push vs modal: ทำให้ฟลูว์ออกง่าย

สร้างสแต็กเต็มรูปแบบโดยไม่ต้องเขียนโค้ดเชื่อมต่อ
สร้าง backend, เว็บ และแอปมือถือพร้อมใช้งานจากการออกแบบฟลูว์เดียวกัน
สร้างแอป

ฟลูว์จะคาดเดาได้เมื่อมีวิธีเดินหน้าหลักเดียว: push หน้าจอบนสแตกเดียว ใช้ sheets และ full-screen covers สำหรับงานข้างเคียง ไม่ใช่เส้นทางหลัก

Push (NavigationStack) เหมาะเมื่อผู้ใช้คาดหวังให้ Back ย้อนขั้นตอน ส่วน modal เหมาะเมื่อผู้ใช้ทำงานข้างเคียง เลือกอย่างรวดเร็ว หรือยืนยันการกระทำที่มีความเสี่ยง

ชุดกฎง่าย ๆ ป้องกันความแปลกประหลาดส่วนมาก:

  • Push สำหรับเส้นทางหลัก (ขั้น 1, ขั้น 2, ขั้น 3)
  • ใช้ sheet สำหรับงานเล็ก ๆ ที่เป็นทางเลือก (เลือกวันที่, เลือกประเทศ, สแกนเอกสาร)
  • ใช้ fullScreenCover สำหรับ “โลกแยก” (ล็อกอิน, ถ่ายภาพกล้อง, เอกสารกฎหมายยาว)
  • ใช้ modal สำหรับการยืนยัน (ยกเลิกฟลูว์, ลบร่าง, ส่งเพื่ออนุมัติ)

ความผิดพลาดที่พบบ่อยคือใส่หน้าจอหลักไว้ใน sheet ถ้าขั้น 2 เป็น sheet ผู้ใช้สามารถปัดเลิกและสูญเสียบริบท ทำให้สแตกบอกว่าพวกเขาอยู่ที่ขั้น 1 ขณะที่ข้อมูลบอกว่าพวกเขาทำขั้น 2 เสร็จแล้ว

การยืนยันเป็นสิ่งตรงข้าม: การ push หน้าจอ “แน่ใจไหม?” เข้ากับวิซาร์ดอาจทำให้สแตกรกและสร้างลูป (Step 3 -> Confirm -> Back -> Step 3 -> Back -> Confirm)

วิธีปิดทุกอย่างอย่างเรียบร้อยหลัง “เสร็จ”

ตัดสินใจว่า “เสร็จ” หมายถึงอะไร: กลับไปหน้าหลัก, กลับไปที่รายการ, หรือแสดงหน้าความสำเร็จ

ถ้าฟลูว์ถูก push ให้รีเซ็ต NavigationPath เป็นค่าว่างเพื่อ pop กลับสู่จุดเริ่มต้น ถ้าฟลูว์ถูก present เป็น modal ให้เรียก dismiss() จาก environment หากคุณมีทั้งสองอย่าง (modal ที่มี NavigationStack) ให้ dismiss modal แทนการ pop แต่ละหน้าจอ หลังการส่งสำเร็จ ให้ล้างร่างด้วยเพื่อให้ฟลูว์ที่เปิดใหม่เริ่มสด

พฤติกรรมปุ่ม Back และช่วง “แน่ใจไหม?”

สร้างฟลูว์หลายขั้นตอนที่คาดเดาได้
สร้างฟลูว์แบบวิซาร์ดด้วยขั้นตอนชัดเจนและข้อมูลร่างที่คงที่ โดยไม่ต้องเขียนการนำทางด้วยมือ
ลองเลย

สำหรับฟลูว์ส่วนใหญ่ การปล่อยให้ระบบจัดการ Back โดยไม่ทำอะไรเป็นการตัดสินใจที่ดี: มันตรงกับที่ผู้ใช้คาดหวังและลดบั๊กที่ UI บอกสิ่งหนึ่งแต่สถานะการนำทางอีกอย่าง

การดักเป็นสิ่งที่คุ้มค่าเมื่อการย้อนกลับจะก่อให้เกิดอันตรายจริง ๆ เช่น สูญเสียฟอร์มที่ยาวและยังไม่บันทึก หรือทิ้งการกระทำที่ไม่สามารถย้อนกลับได้ หากผู้ใช้สามารถกลับและทำต่อได้อย่างปลอดภัย อย่าเพิ่ม摩擦

แนวทางปฏิบัติคือให้ระบบนำทางทำงาน แต่เพิ่มการยืนยันเฉพาะเมื่อหน้าจอ “dirty” ซึ่งหมายถึงการจัดหากลไก back ของตัวเองและถามครั้งเดียว พร้อมทางออกที่ชัดเจน

@Environment(\.dismiss) private var dismiss
@State private var showLeaveConfirm = false
let hasUnsavedChanges: Bool

var body: some View {
  Form { /* fields */ }
    .navigationBarBackButtonHidden(hasUnsavedChanges)
    .toolbar {
      if hasUnsavedChanges {
        ToolbarItem(placement: .navigationBarLeading) {
          Button("Back") { showLeaveConfirm = true }
        }
      }
    }
    .confirmationDialog("Discard changes?", isPresented: $showLeaveConfirm) {
      Button("Discard", role: .destructive) { dismiss() }
      Button("Keep Editing", role: .cancel) {}
    }
}

ป้องกันไม่ให้สิ่งนี้กลายเป็นกับดัก:

  • ถามเฉพาะเมื่อคุณอธิบายผลลัพธ์ได้ในหนึ่งประโยคสั้น ๆ
  • เสนอทางเลือกปลอดภัย (ยกเลิก, แก้ไขต่อ) และทางออกที่ชัดเจน (ทิ้ง, ออกจากหน้าจอ)
  • อย่าซ่อนปุ่ม Back เว้นแต่จะแทนที่ด้วยปุ่ม Back หรือ Close ที่ชัดเจน
  • ถนัดยืนยันการกระทำที่ไม่สามารถย้อนกลับ (เช่น “อนุมัติ”) แทนการบล็อกการนำทางทั่วไป

ถ้าคุณต้องคอยสู้กับ gesture ของ Back บ่อย ๆ นั่นมักเป็นสัญญาณว่าฟลูว์ต้องการ autosave, ร่างที่บันทึก, หรือแบ่งขั้นตอนให้เล็กลง

ความผิดพลาดทั่วไปที่สร้างสแตก Back แปลก ๆ

บั๊ก “ทำไมมันกลับไปที่นั่น?” ส่วนมากไม่ใช่ SwiftUI สุ่ม มันมาจากแพทเทิร์นที่ทำให้สถานะการนำทางไม่เสถียร เพื่อพฤติกรรมที่คาดเดาได้ ให้ปฏิบัติกับสแตก Back เหมือนข้อมูลแอป: เสถียร ทดสอบได้ และมีเจ้าของเพียงที่เดียว

สแตกเกินโดยไม่ตั้งใจ

กับดักทั่วไปคือมี NavigationStack มากกว่าหนึ่งอันโดยไม่รู้ตัว ตัวอย่างเช่น แต่ละแท็บมีสแตกของตัวเอง แล้ว child view เพิ่มสแตกอีกตัวในฟลูว์ ผลคือพฤติกรรม Back สับสน, แถบ navigation หายไป, หรือหน้าจอที่ไม่ pop ตามที่คาด

ปัญหาบ่อยอีกอย่างคือสร้าง NavigationPath ใหม่บ่อยเกินไป ถ้า path ถูกสร้างใน view ที่ re-render มันอาจรีเซ็ตเมื่อ state เปลี่ยนและพาผู้ใช้กลับไปขั้น 1 หลังพิมพ์ข้อมูลในฟิลด์

ข้อผิดพลาดที่อยู่เบื้องหลังสแตกแปลก ๆ มีดังนี้:

  • ซ้อน NavigationStack ภายในสแตกอื่น (มักในแท็บหรือเนื้อหาใน sheet)
  • สร้าง NavigationPath() ขึ้นใหม่ระหว่างการอัพเดต view แทนเก็บไว้ใน state ที่ยืนยาว
  • ใส่ค่าที่ไม่เสถียรใน route (เช่น โมเดลที่เปลี่ยนแปลง) ซึ่งทำลาย Hashable และทำให้ destinaton ผิด
  • กระจายการตัดสินใจการนำทางไปตาม handler ของปุ่มจนครบไม่มีใครอธิบายว่า “ถัดไป” หมายถึงอะไร
  • ขับเคลื่อนฟลูว์จากหลายแหล่งพร้อมกัน (เช่น view model และ view ต่างคนต่างแก้ path)

ถ้าคุณต้องส่งข้อมูลระหว่างขั้น ให้ใช้ตัวระบุที่เสถียรใน route (ID, enum ของขั้น) และเก็บข้อมูลจริงในสเตตแชร์

ตัวอย่างชัดเจน: ถ้า route ของคุณคือ .profile(User) แล้ว User เปลี่ยนขณะพิมพ์ SwiftUI อาจถือเป็น route คนละอันและต่อสแตกผิด ทำให้ปัญหา ให้ใช้ route .profile แล้วเก็บร่างโปรไฟล์ในสเตตแชร์

เช็คลิสต์ด่วนสำหรับการนำทางที่คาดเดาได้

ทดสอบการทำงานของปุ่ม Back ตั้งแต่ต้น
ทำต้นแบบฟลูว์เต็มรูปแบบอย่างรวดเร็ว แล้วปรับหน้าจอโดยไม่ทำให้เส้นทางผู้ใช้พัง
ออกแบบต้นแบบ

เมื่อฟลูว์รู้สึกแปลก มักเป็นเพราะ back stack ไม่เล่าเรื่องเดียวกับที่ผู้ใช้คิด ก่อนจะขัดเกลาหน้า UI ให้ตรวจตรากฎการนำทางของคุณ

ทดสอบบนอุปกรณ์จริง ไม่ใช่แค่ previews และลองแตะช้าและเร็ว การแตะเร็วบ่อย ๆ มักเผยปัญหา push ซ้ำและ state หาย

  • ย้อนกลับทีละก้าวจากหน้าสุดท้ายไปยังหน้าแรก ยืนยันว่าสถานะทุกหน้าจอแสดงข้อมูลที่ผู้ใช้ใส่ก่อนหน้า
  • ทดสอบ Cancel จากทุกขั้นตอน (รวมทั้งแรกและสุดท้าย) ยืนยันว่าส่งกลับไปยังที่สมเหตุสมผลเสมอ ไม่ใช่หน้าก่อนแบบสุ่ม
  • บังคับปิดแอปกลางฟลูว์แล้วเปิดใหม่ ให้แน่ใจว่าสามารถกลับไปได้อย่างปลอดภัย ทั้งโดยการคืนค่า path หรือเริ่มใหม่ที่ขั้นตอนที่รู้จักพร้อมข้อมูลที่บันทึก
  • เปิดฟลูว์ด้วย deep link หรือ shortcut ยืนยันว่าขั้นตอนปลายทางถูกต้อง; ถ้าข้อมูลจำเป็นหาย ให้เปลี่ยนเส้นทางไปยังขั้นตอนแรกที่สามารถเก็บข้อมูลนั้นได้
  • เสร็จด้วย Done และยืนยันว่าฟลูว์ถูกลบอย่างเรียบร้อย ผู้ใช้ไม่ควรกด Back แล้วกลับเข้าไปในวิซาร์ดที่เสร็จแล้วได้

วิธีทดสอบง่าย ๆ: จินตนาการวิซาร์ด onboarding 3 หน้าจอ (Profile, Permissions, Confirm) ใส่ชื่อ เดินหน้า ย้อนกลับ แก้ไข แล้วกระโดดไป Confirm ผ่าน deep link ถ้า Confirm แสดงชื่อเก่า หรือ Back พาคุณไปที่ Profile ซ้ำ แสดงว่าการอัพเดต path ของคุณไม่สอดคล้อง

ถ้าคุณผ่านเช็คลิสต์โดยไม่มีความประหลาดใจ ฟลูว์ของคุณจะรู้สึกนิ่งและคาดเดาได้ แม้ผู้ใช้จะออกไปแล้วกลับมาใหม่

ตัวอย่างที่สมจริงและขั้นตอนถัดไป

ลองนึกถึงฟลูว์การอนุมัติค่าใช้จ่ายของผู้จัดการ มีสี่ขั้น: Review, Edit, Confirm, และ Receipt ผู้ใช้คาดหวังอย่างเดียว: Back พาไปยังขั้นก่อนหน้า ไม่ใช่หน้าที่สุ่มที่เคยเยี่ยมชมก่อนหน้า

enum route ง่าย ๆ จะช่วยให้พยากรณ์ได้ NavigationPath ควรเก็บเฉพาะ route และตัวระบุเล็ก ๆ ที่จำเป็นในการโหลดสเตต เช่น expenseID และ mode (review vs edit) หลีกเลี่ยงการ push โมเดลขนาดใหญ่ที่เปลี่ยนแปลงได้ลงใน path เพราะจะทำให้การ restore และ deep link เปราะบาง

เก็บร่างงานที่กำลังทำในแหล่งความจริงเดียวอยู่นอก view เช่น @StateObject flow model (หรือสโตร์) แต่ละขั้นอ่านและเขียนโมเดลนั้น ดังนั้นหน้าจอจะปรากฏและหายไปโดยไม่สูญเสียอินพุต

อย่างน้อยที่สุด คุณกำลังติดตามสามอย่าง:

  • Routes (เช่น: review(expenseID), edit(expenseID), confirm(expenseID), receipt(expenseID))
  • Data (ออบเจ็กต์ร่างที่มีรายการและหมายเหตุ พร้อมสถานะเช่น pending, approved, rejected)
  • Location (ร่างใน flow model, บันทึก canonical บนเซิร์ฟเวอร์, และ token เล็ก ๆ สำหรับ restore ท้องถิ่น: expenseID + ขั้นตอนล่าสุด)

ขอบเขตคือตรงที่ฟลูว์สร้างความเชื่อมั่นหรือทำลายมัน ถ้าผู้จัดการปฏิเสธในหน้ายืนยัน ให้ตัดสินใจว่า Back จะกลับไป Edit (เพื่อแก้ไข) หรือออกจากฟลูว์ หากพวกเขากลับมาในภายหลัง ให้คืนค่าขั้นสุดท้ายจาก token ที่บันทึกไว้และโหลดร่าง หากเปลี่ยนเครื่อง ให้ใช้เซิร์ฟเวอร์เป็นแหล่งความจริง: สร้าง path จากสถานะบนเซิร์ฟเวอร์แล้วส่งผู้ใช้ไปยังขั้นตอนที่ถูกต้อง

ขั้นตอนถัดไป: เอกสาร enum route ของคุณ (แต่ละ case หมายถึงอะไรและเมื่อใดที่ใช้), เพิ่มการทดสอบเบื้องต้นสำหรับการสร้าง path และการ restore, และยึดกฎเดียว: view ไม่เป็นเจ้าของการตัดสินใจนำทาง

ถ้าคุณกำลังสร้างฟลูว์ประเภทเดียวกันบ่อย ๆ โดยไม่อยากเขียนทุกอย่างใหม่ แพลตฟอร์มอย่าง AppMaster (appmaster.io) นำหลักการแยกนี้ไปใช้: แยกการนำทางขั้นตอนและข้อมูลธุรกิจเพื่อให้หน้าจอเปลี่ยนได้โดยไม่ทำให้ความก้าวหน้าของผู้ใช้พัง

คำถามที่พบบ่อย

How do I keep the Back button predictable in a multi-step SwiftUI flow?

ใช้ NavigationStack กับ NavigationPath เดียวที่คุณควบคุมเอง กด push เพียง route เดียวต่อการกด “ถัดไป” และ pop เพียง route เดียวต่อการกด Back เมื่อคุณต้องกระโดด (เช่น “แก้ไขโปรไฟล์” จากหน้าทบทวน) ให้ตัดเส้นทาง (trim) ถึงขั้นตอนที่ทราบแทนการ push เพิ่ม

Why does my form data disappear when I navigate back?

เพราะ SwiftUI สร้างหน้าจอใหม่จากค่าที่ใช้ในการนำทาง ไม่ใช่จากอินสแตนซ์ view เดิม หากข้อมูลฟอร์มเก็บไว้ใน @State ของ view ข้อมูลจะรีเซ็ตเมื่อ view ถูกสร้างใหม่ ให้เก็บข้อมูลร่างในโมเดลแชร์ (เช่น ObservableObject) ที่อยู่นอก view ที่ถูก push

Why am I seeing the same screen twice in the stack?

มักเกิดจากการ append route เดิมซ้ำหลายครั้ง (เช่นจากการแตะเร็ว ๆ หรือมีหลายโค้ดพาธที่เรียก navigation) ปิดปุ่ม Next ขณะกำลังนำทางหรือขณะรอการตรวจสอบ/โหลด และรวมตรรกะการนำทางให้อยู่ที่เดียวเพื่อให้เกิดการ append เพียงครั้งเดียวต่อขั้น

What should I store in my route enum and what should I keep out of it?

เก็บค่า routing เล็ก ๆ และเสถียร เช่น enum case พร้อม ID น้ำหนักเบา เก็บข้อมูลที่เปลี่ยนแปลงได้ (ร่าง) ใน object แยกต่างหากและมองมันขึ้นด้วย ID เมื่อจำเป็น การ push โมเดลขนาดใหญ่หรือเปลี่ยนแปลงลงไปใน path จะทำให้ Hashable ผิดเพี้ยนและพาธไม่ตรงจุด

How do I separate navigation state from flow state cleanly?

การนำทางคือ “ผู้ใช้อยู่ที่ไหน” ส่วนสเตตคือ “ผู้ใช้ใส่อะไรไปแล้ว” ครอบครอง navigation path ใน router (หรือ state ชั้นบน) และครอบครองร่างใน ObservableObject แยกต่างหาก แต่ละหน้าจอแก้ไขร่าง; router เปลี่ยนเฉพาะขั้นตอน

What’s the safest way to handle deep links into step 3 of a wizard?

มอง deep link เป็นคำสั่งให้สร้างลำดับขั้นตอนที่ถูกต้อง ไม่ใช่การเทเลพอร์ตไปยังหน้าจอเดียว ตรวจสอบว่าผู้ใช้มี prerequisites อะไรบ้าง แล้ว append ขั้นตอนที่ขาดก่อนจะไปยังเป้าหมาย เพื่อให้ back stack ยังคงสอดคล้องและสถานะไม่ใช่ค่าที่ไม่ถูกต้อง

How do I restore a partially finished flow after an app restart?

บันทึกสองอย่าง: route สุดท้ายที่มีความหมาย (หรือตัวระบุขั้นตอน) และข้อมูลร่าง เมื่อรีสตาร์ท ให้สร้าง path ใหม่โดยใช้การตรวจสอบ prerequisites เดิมแล้วโหลดร่าง ถ้าร่างเก่า ให้เริ่มใหม่แต่เติมฟิลด์ล่วงหน้าแทนการวางผู้ใช้กลางวิซาร์ดโดยตรง

When should I push versus present a modal in a multi-step flow?

ใช้ push สำหรับเส้นทางหลักเพื่อให้ Back ถอยตามลำดับตามธรรมชาติ ใช้ sheet สำหรับงานข้างเคียงแบบเลือกได้ และ fullScreenCover สำหรับประสบการณ์แยกโลก เช่น การล็อกอินหรือจับภาพกล้อง หลีกเลี่ยงการใส่ขั้นตอนหลักไว้ใน modal เพราะการปัดเลิกสามารถทำให้ UI กับสถานะฟลูว์ไม่สอดคล้องกัน

Should I override the back gesture to show an “Are you sure?” dialog?

อย่าสกัดกั้นปุ่ม Back โดยอัตโนมัติ ให้ระบบจัดการตามปกติ ยกเว้นเมื่อการกลับจะก่อให้เกิดความเสียหายจริง ๆ เช่น สูญเสียงานที่ยังไม่บันทึก ให้ยืนยันเฉพาะเมื่อหน้าจอ “dirty” เท่านั้น และให้คำอธิบายสั้น ๆ ว่าผลลัพธ์คืออะไร ทางเลือกต้องชัดเจน เช่น Keep Editing และ Discard

What are the most common mistakes that create weird back stack bugs?

สาเหตุทั่วไปคือการซ้อน NavigationStack หลายตัว การสร้าง NavigationPath ใหม่เมื่อ view อัพเดต หรือมีหลายส่วนที่แก้ไข path เก็บ stack หนึ่งชุดต่อฟลูว์ เก็บ path ใน state ที่มีอายุยืน (@StateObject หรือ router) และรวมตรรกะ push/pop ไว้ที่เดียว

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม