SwiftUI Tutorial の応用

## 内容
- SwiftUI Tutorial の応用

## 開発環境
### ハードウエア
|項目||
|---|---|
|PC| MacBook Air(M1,2020） メモリ：16GB ストレージ：1TB |
|実機 | iPhoneSE（2nd Generation 128GB iOS 15.3.1） |
|PCと実機を接続する|[USB-C Digital AV Multiportアダプタ](https://www.apple.com/jp/shop/product/MUF82ZA/A/usb-c-digital-av-multiport%E3%82%A2%E3%83%80%E3%83%97%E3%82%BF)|

### ソフトウエア
|項目||
|---|---|
|言語|Swift 5.5.2|
|IDE|Xcode Ver 13.2.1|
|その他|Visual Studio Code Ver 1.64.2|

### バージョン管理
[GitHub - 実際にコードを確認できます](https://github.com/tanakadaichi1989/Hankyu)


## アプリの内容
### 画面
- 駅一覧画面
- 駅詳細画面
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/3721c88e-f0c6-4a40-9cfb-52116357b1f8.png" width="200"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/fc269495-194c-9243-ba40-18156d06bf04.png" width="200">


### 機能
#### 駅一覧画面
- 路線選択タブで路線を切替
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/9c968278-935d-1ac8-a8a7-f9e732237843.png" width="200"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/f5bc86b2-f717-4df8-ce8f-0d72911c5411.png" width="200"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/8e4969b3-98ec-206f-8b13-b179e84fda06.png" width="200">

- お気に入りのみ表示
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/7b62ca98-34bc-eccd-e16d-71176b391d8e.png" width="200">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/b34d8056-3d04-e2a0-a0d0-b683371c4b31.png" width="200">

#### 駅詳細画面
- お気に入り機能
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/20c207c9-2418-e861-4d2d-d9ee6ed2005e.png" width="200"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/d21c4442-9101-f876-b60d-c61275054201.png" width="200">

- 隣の駅を表示
  - ただし大阪梅田駅は、次の駅のみ（宝塚・神戸三宮・京都河原町方向）を表示
  - ただし宝塚駅、神戸三宮駅および京都河原町駅は、前の駅のみ（大阪梅田方向）を表示
  - <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199441/e55115b2-db92-4354-55d4-f9ef93a94608.png" width="300">


## 応用
### 画面上部にタブを追加
- LNE / MoneryForward ME 等に実装されている

```swift:駅一覧画面
import SwiftUI

struct StationListView: View {
    @EnvironmentObject var modelData: ModelData
    @State private var showFavoriteOnly = false
    @State private var selection: Line = .Takarazuka

    var filteredStations: [Station] {
        modelData.stations.filter { station in
            (!showFavoriteOnly || station.isFavorite) && station.line == selection
        }
    }
    
    var body: some View {
        // GeometryReader　を使うことがポイント                    
        GeometryReader { geometry in
            NavigationView {
                VStack(spacing: .zero) {
                    // LineTabView() で画面上部のタブを実装
                    LineTabView(selection: $selection, geometrySize: geometry.size)
                    
                    List{
                        Toggle(isOn: $showFavoriteOnly){
                            Text("お気に入りのみ表示")
                        }
                        
                        ForEach(filteredStations) { station in
                            NavigationLink {
                                StationDetail(station: station)
                            } label: {
                                StationRow(station: station)
                            }
                        }
                    }
                    Spacer()
                }
                .navigationBarTitle("駅一覧",displayMode: .inline)
            }
            .navigationViewStyle(.stack)
            // iPad のサイドビューでは画面上部タブのレイアウトが崩れるため、.navigationViewStyle を .stack に指定している
        }
    }
}

struct StationListView_Previews: PreviewProvider {
    static var previews: some View {
        StationListView()
            .environmentObject(ModelData())
    }
}

```

```swift:画面上部のタブ
import SwiftUI
import MapKit

struct LineTabView: View {
    
    @Binding var selection: Line
    var geometrySize: CGSize
    
    var body: some View {
        HStack(spacing: .zero) {
            ForEach(Line.allCases,id: \.self){ line in
                VStack {
                    Button {
                        self.selection = line
                        print("\(line.getLineName())を選択しました")
                    } label: {
                        Text("\(line.getLineName())")
                            .font(.body)
                            .fontWeight(.heavy)
                            .foregroundColor(self.selection == line ? line.getLineColor() : .gray)
                    }
                    .frame(width: geometrySize.width /  CGFloat(Line.allCases.count), height: 25)
                    
                    Rectangle()
                        .fill(self.selection == line ? line.getLineColor() : .gray)
                        .frame(width: geometrySize.width / CGFloat(Line.allCases.count), height: 2)
                }
                
            }
        }
    }
}

struct LineTabView_Previews: PreviewProvider {
    static var geometry: CGSize = CGSize(width: 200, height: 44)
    static var previews: some View {
        LineTabView(selection: .constant(.Takarazuka), geometrySize: geometry)
    }
}

```

## その他
### 隣の駅を表示する処理のテストコードを書く & XCode でテストを実行
```swift
import XCTest
@testable import Sample

class SampleTests: XCTestCase {

    // 省略
    
    func testGetNextStation() throws {
        var count = 0
        let stations = ModelData().stations
        
        stations.map { station in
            print("\(count) : \(station)")
            count += 1
        }
        
        let takarazukaOsakaUmeda = stations[0]
        let takarazukaNakatsu = takarazukaOsakaUmeda.getNextStation()
        // 期待する結果とコードの処理結果が等しければ、SUCCESS
        XCTAssertEqual(takarazukaNakatsu?.id, "takarazuka02")
        
        let takarazukaTakarazuka = stations[18]
        XCTAssertEqual(takarazukaTakarazuka.getNextStation()?.id, nil)
        
        let kobeOsakaUmeda = stations[19]
        let kobeNakatsu = kobeOsakaUmeda.getNextStation()
        XCTAssertEqual(kobeNakatsu?.id, "kobe02")
        
        let kobeKobeSannomiya = stations[34]
        XCTAssertEqual(kobeKobeSannomiya.getNextStation()?.id, nil)
        
        let kyotoOsakaUmeda = stations[35]
        let kyotoJuso = kyotoOsakaUmeda.getNextStation()
        XCTAssertEqual(kyotoJuso?.id, "kyoto03")
        
        let kyotoKyotoKawaramachi = stations[62]
        XCTAssertEqual(kyotoKyotoKawaramachi.getNextStation()?.id, nil)
    }
    
    func testGetPreviousStation() throws {
        let stations = ModelData().stations
        
        let takarazukaOsakaUmeda = stations[0]
        XCTAssertEqual(takarazukaOsakaUmeda.getPreviousStation()?.id, nil)
        
        let takarazukaNakatsu = stations[1]
        XCTAssertEqual(takarazukaNakatsu.getPreviousStation()?.id, takarazukaOsakaUmeda.id)
        
        let takarazukaTakarazuka = stations[18]
        XCTAssertEqual(takarazukaTakarazuka.getPreviousStation()?.id, "takarazuka55")
    }

    // 省略
    
}

```

```txt:テスト実行結果
Test Successed
```


## 今後
- おでかけスポット画面を追加
- GitHub Actions を試してみる

## 感想
- 慣れたら UIKit よりもラク
- ダークモードが自動で反映されるのでラク
- Enum を上手く使うと改修がラク

## 参考資料
#### SwiftUI Tutorial
https://developer.apple.com/tutorials/swiftui/building-lists-and-navigation

#### 画面上部のタブを実装する際に参考にした記事
https://zenn.dev/milanista224/articles/townranking2021-swiftui