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を選択してください。
この記事の流れで作っている方はそうしないと実行時エラーになると思います。
Generating Previews Dynamically
preview画面でiOS/iPad等別々に表示できますよ的なことなのでここだけ割愛
以上でチュートリアル2終了ですお疲れ様でございました。
所感
ListViewの簡単さ、NavigationViewの容易さはちょっとびっくりしました。
何ができて何ができないのか?が気になってきたので、もっと深ぼって行きたいと思いました。
うーん、SwiftUI、俺は嫌いじゃないぜ。