[Day 20] 里程定位與地圖顯示(六)- 佈局調整

今天我們先繼續把 UI 調整跟草圖接近一致。目前上半部的選項與下方的地圖區塊是分開的,為了讓選項卡片能夠疊在地圖上,我們要將這些既有元件放到 ZStack 裡。 var body: some View { ZStack(alignment: .top) { Map(position: $cameraPosition) { ForEach(pins) { pin in Annotation("", coordinate: pin.coordinate) { // ... } } } } VStack { VStack(spacing: 18) { Picker("公路類型", selection: $category) { ForEach(RoadCategory.allCases) { category in Text(category.rawValue).tag(category) } } .pickerStyle(.segmented) HStack { RoadPickerView( availableRoads: availableRoadNumbers, selection: $selectedRoad ) // ... TextField("輸入里程", text: $mileageText) // ... Button(action: { // ... }) { } } // 上半部卡片樣式 .padding(EdgeInsets(top: 25, leading: 16, bottom: 25, trailing: 16)) .background(.ultraThinMaterial) .cornerRadius(20) .shadow(color: .black.opacity(0.1), radius: 10, y: 5) } } } 最底層是 Map,接著是包含著公路類型選擇 Picker、道路選擇 Menu及里程輸入欄的 VStack。另外,使用 .background(.ultraThinMaterial) 套用一些毛玻璃效果與陰影,微微透視後面的 Map,整體而言會更自然,效果如下: ...

October 3, 2025 · 1 分鐘

[Day 19] 里程定位與地圖顯示(五)- 自訂元件樣式

今天繼續刻 UI! 在 SwiftUi 中自訂元件樣式 當你使用 SwiftUI 框架提供的元件,通常來說你能夠自訂的部分不多,不然就會受到許多限制。以 Picker 來說,你能改的部分大概就是選擇/未被選擇的字體顏色、背景等。如果你要完全客製化,那你可能得自己使用其他元件來自己刻。 除了自己刻之外,一般來說方法有: 調用 UIKit 方法 使用 UISegmentedControl.appearance()。 這是使用 UIKit 的方式來更改元件外觀,但會影響到 App 中 所有的 Segmented 外觀。可以在 View 的 init() 或在 Picker 的 .onAppear 修飾符來設定,例如: struct ContentView: View { // ... init() { UISegmentedControl.appearance().setTitleTextAttributes( [.foregroundColor: UIColor.systemTeal], for: .selected ) } // ... } 使用第三方函式庫 另一個方法是使用第三方函式庫(例如 Introspect) 若你不想更動 App 中所有的 Segmented,只希望針對特定的 Picker 進行修改,可以使用 Introspect 這類函式庫。它能讓你取得 SwiftUI 元件背後的 UIKit 元件,並直接對其進行設定。 ...

October 2, 2025 · 2 分鐘

[Day 18] 里程定位與地圖顯示(四)- Picker 滑動時會亂跳?開立 bug 單吧!

在我們昨天的進度中,我們建立了一個選擇道路的 Picker。但正當我興高采烈地測試時,發現了一個奇怪的 bug:當我快速滑動 Picker 的選項列表,手指一放開,列表的慣性滑動動畫到一半,它就自己重新整理了。 我的第一個反應是:「蝦米,這啥鬼?」我反覆檢查程式碼,Picker 的 selection 明明只綁定了 @State private var selectedRoad,在滑動過程中,這個變數的值也沒改變,為什麼它會自己跳回去?難道是 SwiftUI 的 bug? Troubleshooting SwiftUI View 的計算本質 經過一番研究(問 AI…XD),我發現問題的根源要回到 SwiftUI 對於 View 的本質: 在 SwiftUI 裡,View 不是靜態的畫面,而是由「狀態 (State)」推導出來的結果。 我們透過以下簡單的例子再一次複習 SwiftUI 畫面更新的概念: struct CounterView: View { @State private var count = 0 var body: some View { VStack { Text("Count: \(count)") Button("加一") { count += 1 } } } } 這裡的 body 是一個 compute property,它的職責是根據目前的狀態 count,回傳一個描述 UI 的藍圖。輸入是 count 的值,輸出是一個新的 View,只要 count 改變,整個 body就會重新被計算,畫面就會跟著更新。這表示,只要一個 View 所依賴的任何一個「狀態來源」(@State, @StateObject 等) 發生改變,SwiftUI 就會重新執行這個 View 的 body 屬性,計算出一個新的 View 結構。 ...

October 1, 2025 · 2 分鐘

[Day 17] 里程定位與地圖顯示(三)- 實作搜尋邏輯

今天要來填上核心的「搜尋」與「顯示」邏輯了,包含處理里程輸入,接收並驗證使用者輸入的公里數,並實作搜尋函式,處理不同里程格式的解析,找出最近的地理位置,最後在地圖上顯示結果。 實作搜尋邏輯 filter 高階函式 在搜尋按鈕按下之後,我們要執行的是 searchAction() 這個函式。第一步,透過 filter 此另一個 Swift 的高階函式來篩選我們要的資料: private func searchAction() { let candidates = dataManager.highwayMarkers.filter { $0.roadNumber == selectedRoad } } filter 函式的呼叫,它需要一個 closure 作為參數,這個 closure 就是你的「篩選條件」。filter 會依序將 highwayMarkers 陣列中的每一個元素傳入這個 closure。Closure 會回傳一個布林值,如果 closure 對某個元素回傳 true,filter 就會把這個元素保留下來,放進新的陣列;如果回傳 false,這個元素就會被丟棄。 因此,{ $0.roadNumber == selectedRoad } 表示,目前正在檢查的這個物件,它的 roadNumber 是否等於使用者選擇的 selectedRoad?假設要搜尋的是國道 1 號,最後 filter 會回傳一個新的陣列 candidates,裡面只包含所有 roadNumber 是國道 1號的 HighwayMileageMarker 物件。這個 candidates 陣列就是我們接下來要進行里程搜尋的目標資料。 泛型、Closure 與 KeyPath 在這個 App ,核心功能是根據使用者輸入的里程,從資料中找出最接近的地理位置。然而,國道省道的資料來源、格式不盡相同。例如,國道的里程牌面可能是「014K+800」,而省道則是「5.1」這樣的浮點數。如果為兩者各寫一套搜尋邏輯,會導致程式碼大量重複且難以維護。為了解決這個問題,因此可以設計一個通用的搜尋函式。 為了達到這個目的,必須運用到 Swift 的幾個功能: 泛型 (Generics):我們用泛型 <T> 來定義這個函式,使其不限定處理特定的資料型別。這讓函式的宣告看起來像這樣,其中 T 可以是任何我們想傳入的資料型別: private func findClosestMarker<T>( in items: [T], targetMile: Double, mileExtractor: (T) -> Double?, titleExtractor: (T) -> String, latKey: KeyPath<T, Double>, lonKey: KeyPath<T, Double>, maxAllowedDiffKm: Double? = nil ) -> // ... 回傳值 這樣一來,不論傳入的是國道標記陣列還是省道標記陣列都能處理。 ...

September 30, 2025 · 3 分鐘

[Day 16] 里程定位與地圖顯示(二)- Enum 與 Picker 搭配

今天要做另一個重要功能,我們要讓使用者能根據選擇的公路類型(國道/省道)和輸入的里程數,從預載的 CSV 資料中進行搜尋,並在地圖上精準標示出對應的地理位置。 可以在 Description 當中詳細描述這張 task 要完成些什麼事情,訂定「驗收標準(Acceptance Criteria)」。這是用來明確定義該 task 完成的條件和品質要求,確保開發人員和 user 對任務的完成有共同認知。即使是 Basic 架構,清楚的驗收標準依然能幫助提升開發效率和品質,避免誤解或遺漏需求。 那就來解決掉這張 task 吧! 任務拆分 里程搜尋這個任務,可以拆分成三大區塊: 輸入 選擇國道/省道 選擇道路 里程輸入(公里) 搜尋 從 CSV 解析後的物件中,篩出該道路的所有里程點 轉為可比較的「公里數」再找最近距離 設定一個最大容忍差距(例如 2 公里),超過就視為查無合理結果 顯示 地圖置中到結果的區域 放上一個大頭針(顯示牌面或公里數)。 但是,其實資料來源內容不太相同,解析規則要怎麼處理就會是個問題,例如國道牌面格式是「014K+800」,省道是「5.1」這種浮點數字串。重點是要將把人看得懂的牌面,轉成程式能比較的數字。哪些算、哪些不算,遇到異常如何處理,重點應該在這裡。 至於搜尋邏輯採「最近距離」而不是「完全匹配」的原因很簡單:資料可能不完整。這是資料源的限制,只能說這是一種取捨。如果最近的點也超過 2 公里,就直接回「查無合理結果」,避免在資料有缺或輸入不準時,硬給一個很遠的點誤導使用者。若兩個點距離一樣近,可以選「里程較小」或「較大」,比較符合沿著里程增加方向搜尋的直覺。 另外,搜尋邏輯的效能,先採取 linear time 就好,先把功能跑起來,目前手機端資料量還在可接受範圍內,未來若有進一步需要,效能不夠再談索引或空間資料結構,現階段主要先以完成 MVP 為主。 使用 Picker 建立道路選擇器 Enum 的運用 我們目前的資料有國道與省道,因為之後的資料、邏輯都會個別綁定在這兩個類型上,因此我們可以用 enum 來列舉這兩個項目: enum RoadCategory: String, CaseIterable, Identifiable { case highway = "國道" case provincial = "省道" var id: String { rawValue } } 這裡遵循了兩個特殊協定,CaseIterable 是為了讓 enum 能用 allCases 列出所有選項,方便做需要迭代的 Picker/Segment。而 Identifiable + var id 是讓每個選項有唯一識別,如此一來可以用 rawValue 當 id,可直接被 ForEach 使用,不必再加上 id: \.self。 ...

September 29, 2025 · 2 分鐘