19
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

SwiftUIチュートリアルを雑にやってみるその2

Posted at

https://developer.apple.com/tutorials/swiftui/building-lists-and-navigation
コレやっていきましょう
まじで雑にやっていくので、コレは違うわいクソボケがというところあったらコメントなりなんなりください。

Building Lists and Navigation編

Xcode-betaは事前にインスコしといてください。
preview機能はOSもcatalinaでないと使えないようです。

プロジェクトは前回のものを引き続き使います。

今回はListViewを作ります。

おなじみリストViewです。
まずはモデルから作っていくようです。

In the Project navigator, choose Models > Landmark.swift.

プロジェクトナビゲーターからModelsを選んでLandmark.swift!
全く意味がわかりません。プロジェクトナビゲーターは左側の~~.swiftとかAssets.xcassetとかが並んでるところですね。
Modelsを選んでと言われてもそんなものありません。仕方ないのでゴリ押しで行きます。

今までファイルを作っていた階層にnew Groupを作ります。名前はModelsで。
おそらくmodelを作ってここで管理するのでしょう。
そのModels配下にnew FileでLandmark.swiftを作成しましょう。
たぶんSwift Fileで十分です。

中身はそのままコピペしていきましょう。

import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable {
    var id: Int
    var name: String
    fileprivate var imageName: String
    fileprivate var coordinates: Coordinates
    var state: String
    var park: String
    var category: Category

    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude)
    }

    func image(forSize size: Int) -> Image {
        ImageStore.shared.image(name: imageName, size: size)
    }

    enum Category: String, CaseIterable, Codable, Hashable {
        case featured = "Featured"
        case lakes = "Lakes"
        case rivers = "Rivers"
    }
}

struct Coordinates: Hashable, Codable {
    var latitude: Double
    var longitude: Double
}

ImageStoreってなんやねんみたいな怒られも発生しつつ、とりあえず次に進みます。

In the Project navigator, choose Resources > landmarkData.json.

データソースになるjsonをResoureces配下に作れとおっしゃっています。
これもゴリ押しでLandmark.swiftと同じように作ってみましょう。後は野となれ山となれです。
なぜかキャメルケースなんですが、swift界ではResources配下のファイルはキャメルケースというお約束があるんでしょうか?
Resourcesディレクトリですが、以下のように配置することでbundleに認識されるようです。

hoge
├── Resources
│   └── landmarkData.json
├── hoge
│   ├── AppDelegate.swift
│   ├── Base.lproj
│   │   └── LaunchScreen.storyboard
│   ├── CircleImage.swift
│   ├── Info.plist
│   ├── LandmarkDetail.swift
│   ├── LandmarkList.swift
│   ├── LandmarkRow.swift
│   ├── Map.swift
│   ├── Models
│   │   ├── Data.swift
│   │   └── Landmark.swift
│   └── SceneDelegate.swift

ファイル作成時にGeojson Fileの選択肢しかなかったので、無理やりlandmarkData.jsonにして保存しました。
中身はコピペしてちょっといじりましょう。

[
    {
        "name": "Turtle Rock",
        "category": "Featured",
        "city": "Twentynine Palms",
        "state": "California",
        "id": 1001,
        "park": "Joshua Tree National Park",
        "coordinates": {
            "longitude": -116.166868,
            "latitude": 34.011286
        },
        "imageName": "turtlerock"
    },
    {
        "name": "Silver Salmon Creek",
        "category": "Lakes",
        "city": "Port Alsworth",
        "state": "Alaska",
        "id": 1002,
        "park": "Lake Clark National Park and Preserve",
        "coordinates": {
            "longitude": -152.665167,
            "latitude": 59.980167
        },
        "imageName": "silversalmoncreek"
    }
]

ガッツリ脂の乗ったjsonですね。こいつぁ活きが良いや!

Note that the ContentView type from Creating and Combining Views is now named LandmarkDetail.

その次に前回作ったcontentView.swiftをrenameしましょう。ファイルを開いてContentViewの部分を右クリックし、refactorからrenameでパッと変えられます。

このまま次に行く前にImageStoreってなんやねんて怒られたままなので、定義しましょう。
チュートリアルのproject filesにData.swiftというファイルがありますのでまるまるコピーします。内容は各自確認してください。

import UIKit
import SwiftUI
import CoreLocation

let landmarkData: [Landmark] = load("landmarkData.json")

func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
    let data: Data
    
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            fatalError("Couldn't find \(filename) in main bundle.")
    }
    
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
    
    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

final class ImageStore {
    fileprivate typealias _ImageDictionary = [String: [Int: CGImage]]
    fileprivate var images: _ImageDictionary = [:]
    
    fileprivate static var originalSize = 250
    fileprivate static var scale = 2
    
    static var shared = ImageStore()
    
    func image(name: String, size: Int) -> Image {
        let index = _guaranteeInitialImage(name: name)
        
        let sizedImage = images.values[index][size]
            ?? _sizeImage(images.values[index][ImageStore.originalSize]!, to: size * ImageStore.scale)
        images.values[index][size] = sizedImage
        
        return Image(sizedImage, scale: Length(ImageStore.scale), label: Text(verbatim: name))
    }
    
    fileprivate func _guaranteeInitialImage(name: String) -> _ImageDictionary.Index {
        if let index = images.index(forKey: name) { return index }
        
        guard
            let url = Bundle.main.url(forResource: name, withExtension: "jpg"),
            let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
            let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
            else {
                fatalError("Couldn't load image \(name).jpg from main bundle.")
        }
        
        images[name] = [ImageStore.originalSize: image]
        return images.index(forKey: name)!
    }
    
    fileprivate func _sizeImage(_ image: CGImage, to size: Int) -> CGImage {
        guard
            let colorSpace = image.colorSpace,
            let context = CGContext(
                data: nil,
                width: size, height: size,
                bitsPerComponent: image.bitsPerComponent,
                bytesPerRow: image.bytesPerRow,
                space: colorSpace,
                bitmapInfo: image.bitmapInfo.rawValue)
            else {
                fatalError("Couldn't create graphics context.")
        }
        context.interpolationQuality = .high
        context.draw(image, in: CGRect(x: 0, y: 0, width: size, height: size))
        
        if let sizedImage = context.makeImage() {
            return sizedImage
        } else {
            fatalError("Couldn't resize image.")
        }
    }
}

チュートリアルとして綺麗なとこだけ見せたかったんですかね。

Create the Row View

リストの元となる行Viewを作っていきましょう。
多分その行をクリックするとDetailViewが表示されるとかそんなやつ。

Create a new SwiftUI view, named LandmarkRow.swift.

LandmarkRow.swiftをSwiftUI Viewで作っていきます。
デフォルトで

import SwiftUI

struct LandmarkRow : View {
    var body: some View {
        Text("Hello World!")
    }
}

#if DEBUG
struct LandmarkRow_Previews : PreviewProvider {
    static var previews: some View {
        LandmarkRow()
    }
}
#endif

こんな感じになっているはず。ここでpreviewを有効化してくださいとか抜かしてくるので無視します。男は黙ってViewは勘で作る。(使える方は使える方は使ってください)

import SwiftUI

struct LandmarkRow : View {
    var landmark: Landmark
    
    var body: some View {
        HStack { //HorizontalStack横にViewが積み上がる
            landmark.image(forSize: 50) //Landmarkから画像Viewの呼び出し
            Text(landmark.name)
        }
    }
}

#if DEBUG
struct LandmarkRow_Previews : PreviewProvider {
    static var previews: some View {
        LandmarkRow(landmark: landmarkData[0]) // jsonデータから先頭データを取得
    }
}
#endif

こんな感じになりましたね。
これでリストに表示される行Viewができました。
あってるかどうかXcodeのlintがギンギンになってるので不安しかないですが進めていきましょう。

Customize the Row Preview

preview用に見た目をカスタマイズすることができます!なんてすごい機能なんだ!やばいね!

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        Group { // まとめて
            LandmarkRow(landmark: landmarkData[0])
            LandmarkRow(landmark: landmarkData[1])
        }
        .previewLayout(.fixed(width: 300, height: 70)) //見た目変えられる
    }
}

Create the List of Landmarks

Create a new SwiftUI view, named LandmarkList.swift.

毎度おなじみViewを作りましょう。ここまででファイル構成がメチャクチャなのでディレクトリのベストプラクティスが知りたい。
内容はこんな感じ

import SwiftUI

struct LandmarkList : View {
    var body: some View {
        List {
            LandmarkRow(landmark: landmarkData[0])
            LandmarkRow(landmark: landmarkData[1])
        }
    }
}

#if DEBUG
struct LandmarkList_Previews : PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}
#endif

もしかしてList {}だけでリストViewが作れるのか?

Make the List Dynamic

いい加減チュートリアルクソ長くないですか?そう思ってるの僕だけですか?
動的にリストを作りましょうのコーナーです。
先程のLandmarkListをちょっと手を加えてデータ配列によって動的にリストが表示されるようにします。

import SwiftUI

struct LandmarkList : View {
    var body: some View {
         List(landmarkData.identified(by: \.id)) { landmark in
             LandmarkRow(landmark: landmark)
         }
    }

}

#if DEBUG
struct LandmarkList_Previews : PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}
#endif

Listにデータ配列を渡してクロージャでViewを返すようにしてあげればいいみたいです。

チュートリアルの方では

         List(landmarkData) { landmark in
             LandmarkRow(landmark: landmark)
         }

とスッキリ書かれていて非常に気持ちいいですが、弊環境ではbuildできませんでした。なんで?

Set Up Navigation Between List and Detail

リストとデテールをセットアップしましょう!

Embed the dynamically generated list of landmarks in a NavigationView

まずナヴィゲーションViewでListViewをくるんでやります。

struct LandmarkList : View {
    var body: some View {
        NavigationView{
            List(landmarkData.identified(by: \.id)) { landmark in
                LandmarkRow(landmark: landmark)
            }
        }
    }
}

Call the navigationBarTitle(_:) modifier method to set the title of the navigation bar when displaying the list.

ナビゲーションViewにタイトルをつけましょう。

  NavigationView{
            List...
  }.navigationBarTitle(Text("Landmarks"))

簡単ですね。

Inside the list’s closure, wrap the returned row in a NavigationButton, specifying the LandmarkDetail view as the destination.

中のクロージャでナヴィゲーションButtonでラップしてデテールとデスティネーションだそうです。

僕の場合はなのですが、以下のような状態になりました。

import SwiftUI

struct LandmarkList : View {
    var body: some View {
        NavigationView{
            List(landmarkData.identified(by: \.id)) { landmark in
                NavigationButton(destination: LandmarkDetail()) {
                    LandmarkRow(landmark: landmark)
                }
            }
        }.navigationBarTitle(Text("Landmarks"))
    }
}

#if DEBUG
struct LandmarkList_Previews : PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}
#endif

うーん、コレでまじで動くのか?

Pass Data into Child Views

子Viewをいじっていくよ。
前回作ったCircleImageをいじいじするよ。

import SwiftUI

struct CircleImage : View {
    var image :Image
    var body: some View {
        image
        .clipShape(Circle())
        .overlay(Circle().stroke(Color.gray, lineWidth: 4))
        .shadow(radius: 10)
    }
}

#if DEBUG
struct CircleImage_Previews : PreviewProvider {
    static var previews: some View {
        CircleImage(image: Image("turtlerock"))
    }
}
#endif

なんとなくわかってきたのはViewを継承したstructはbodyにViewをハメることでViewをカスタマイズできるということだと思う。someの意味がわからん。
structの要素がデータだったりViewだったりがもててカスタムViewに使用できる。そんな感じじゃあないかと思った。

次Map

import SwiftUI
import MapKit

struct Map : UIViewRepresentable {
    var coordinate: CLLocationCoordinate2D
    
    func makeUIView(context: Context) -> MKMapView {
        MKMapView(frame: .zero)
    }
    
    func updateUIView(_ view: MKMapView, context: Context) {
        
        let span = MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
        let region = MKCoordinateRegion(center: coordinate, span: span)
        view.setRegion(region, animated: true)
    }
}


#if DEBUG
struct Map_Previews : PreviewProvider {
    static var previews: some View {
        Map(coordinate: landmarkData[0].locationCoordinate)
    }
}
#endif

var coordinateを追加することによって決め打ちだった地図上のポイントが外部から設定できるようになったね。

次デテールViewを編集、今までMap,CircleImageに対する引数を追加する変更だったから、それを修正して、更に引数でもらったデータで表示情報を変えることにしよう。

import SwiftUI

struct LandmarkDetail : View {
    var landmark: Landmark //要素追加
    var body: some View {
        VStack{
            Map(coordinate: landmark.locationCoordinate) //landmarkからデータを引っ張ってくる
                .frame(height: 300)
            CircleImage(image: landmark.image(forSize: 250)) //landmarkからデータを引っ張ってくる
                .offset(y: -130)
                .padding(.bottom, -130)
            VStack(alignment: .leading){
                Text(landmark.name) //landmarkからデータを引っ張ってくる
                HStack{
                    Text(landmark.park) //landmarkからデータを引っ張ってくる
                    Spacer()
                    Text(landmark.state) //landmarkからデータを引っ張ってくる
                }
            }.padding()
            Spacer()
        }.navigationBarTitle(Text(landmark.name), displayMode: .inline)
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: landmarkData[0])
    }
}
#endif

いい感じ。

そして、最後に重要なのは、アプリの最初の画面をどのViewに紐付けるか!

SceneDelegate.swiftの24行目

window.rootViewController = UIHostingController(rootView: 
LandmarkDetail())

window.rootViewController = UIHostingController(rootView: LandmarkList())

に書き換えよう!

よーし、buildや!
と、その前に、このチュートリアルでは、Resources配下にjpg画像が配置されていることが前提になっているので、landmarkData.jsonで設定したimageNameと同じ名前の画像.jpgを配置してください。
またResourcesディレクトリを選択肢、FileInspectorからLocation>Relative to projectを選択してください。
この記事の流れで作っている方はそうしないと実行時エラーになると思います。

気を取り直してbuild!
スクリーンショット 2019-06-06 01.49.10.pngスクリーンショット 2019-06-06 01.49.34.png

Generating Previews Dynamically

preview画面でiOS/iPad等別々に表示できますよ的なことなのでここだけ割愛

以上でチュートリアル2終了ですお疲れ様でございました。

所感

ListViewの簡単さ、NavigationViewの容易さはちょっとびっくりしました。
何ができて何ができないのか?が気になってきたので、もっと深ぼって行きたいと思いました。
うーん、SwiftUI、俺は嫌いじゃないぜ。

19
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?