Swift Playgrounds「自分だけの物語を選択する」を解説する

# この投稿はなに？
iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「自分だけの物語を選択する」Appプロジェクトを学ぶための解説です。

新しくなったSwift Playgrounds4については、[以前の投稿](https://qiita.com/imchino/items/01d46c27c6a400aad1de)で解説しています。
# 「自分だけの物語を選択する」Appプロジェクトの概要
このAppプロジェクトは、Storyという名前のアプリを作成します。読者となるユーザーは、要所で表示される選択肢を選びながら物語を進めていきます。
下の画像はStoryアプリを起動した直後の画面ですが、物語のテキストの下に選択肢が3つあることを確認できます。
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/bb4fa9fb-1cb9-006f-9275-f819ccc34ddf.jpeg">

プロジェクトの実行ボタンをクリックして、アプリの動作を確認しておきましょう。
適当な選択肢を選ぶと、物語のページを進めることができます。また、アプリのナビゲーションリンクをクリックして、前のページに戻ることもできます。
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/de8f9499-83b2-4402-48c5-ff30e98dee29.jpeg">

物語のテキストが長い場合は縦にスクロールできるようになっています。また、ページによって表示される選択肢の数が増えたり減ったりしていることにも注目してください。

# リソースのファイルを確認する

![IMG_0507.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/f9052095-62d6-0262-a89d-b698e5c5d8a2.jpeg)

## StoryApp.swift
Storyアプリ自体を定義するファイルです。このAppプロジェクトの学習目標ではないので、理解する必要はありません。

```swift:
import SwiftUI

@main
struct StoryApp: App {
    var body: some Scene {
        WindowGroup {
            StoryView()
        }
    }
}
```

なお、`@main`属性はプログラム全体のエントリーポイントを示すキーワードです。アプリを起動すると、このキーワードの箇所から実行されることになります。

## StoryModels.swift
進行可能な物語を作るために必要な3つの構造体（`Story`型、`StoryPage`型、`Choice`型）を定義したファイルです。

```swift:StoryModels.swift
import Foundation

struct Story {
    let pages: [StoryPage]

    subscript(_ pageIndex: Int) -> StoryPage {
        return pages[pageIndex]
    }
}

struct StoryPage {
    let text: String
    
    let choices: [Choice]
    
    init(_ text: String, choices: [Choice]) {
        self.text = text
        self.choices = choices
    }
}

struct Choice {
    let text: String
    let destination: Int
}
```

### Story型
アプリで語られる「物語の全て」を定義した構造体です。
一画面分の`StoryPage`型インスタンスを1ページとして、その配列を`pages`プロパティに保持します。

### StoryPage型
この構造体のインスタンスは、一画面分に表示される「物語」と「いくつかの選択肢」をプロパティとして保持します。
「いくつかの選択肢」を示す`choices`プロパティは、実際のところ、`Choice`型の配列です。

### Choice型
物語の要所でユーザーに示される選択肢です。
この構造体のインスタンスは、「表示するテキスト」と「物語の進み先」をプロパティとして保持します。

## MyStory.swift
このファイルでは、アプリで語られる「物語の全て」を保持する`Story`型のインスタンスを作成しています。ここで作成しているインスタンスはプロジェクトのどこからでも、定数`story`として呼び出せます。

```swift:MyStory.swift
import Foundation
import SwiftUI

let story = Story(pages: [
    StoryPage( // 0
        """
        Welcome to Choose Your Own Story
        🥖🍪Bake Off!🥐🍰
        
        You enter a local baking competition at the county fair 🎪. It’s a beautiful summer day and you are excited! When you enter the room, you look around to see ten identical stations.
        
        Each station is stocked with bowls of different sizes, spatulas, spoons, and a standing mixer. You are the third to arrive, so there are many stations still available. Where do you sit?
        """,
        choices: [
            Choice(text: "Front row!", destination: 1),
            Choice(text: "Find somewhere in the middle", destination: 1),
            Choice(text: "Back of the room", destination: 2),
        ]
    ),
    StoryPage( // 1
        """
        Luckily, some nice people fill in around you and wave politely. The directions sounds fun! Make a sponge cake you would have wanted as a child for your birthday, focusing on the decorations and theme. The top three bakers will win a cash prize. You start with a theme.
        """,
        choices: [
            Choice(text: "Trains", destination: 3),
            Choice(text: "Castles", destination: 5),
            Choice(text: "Rainbows", destination: 6),
            Choice(text: "Woodland creatures", destination: 5),
            Choice(text: "Outer space", destination: 3),
        ]
    ),
    /*#-code-walkthrough(Models.storyPageInitializer.addPage)*/
    StoryPage( // 2
        """
        Oh no. There is a very loud person next to you. You can barely hear the directions in the back of the room. You think they said something about a birthday cake. Luckily, the person in front of you whispers a brief review of what they said...glazing over a few things. “A birthday cake I would have wanted as a kid.” Hmm? You think back to your favorite cake.
        """,
        choices: [
            Choice(text: "Chocolate", destination: 4),
            Choice(text: "Red velvet", destination: 4),
            Choice(text: "Vanilla confetti", destination: 5),
            Choice(text: "Strawberry shortcake", destination: 3),
        ]
    ),
    StoryPage( // 3
        """
        Great choice! Now you just need to make the sponge cake and start thinking about the toppings. You scan the selection on the table and start planning your toppings. In addition to all the colorful icing, you choose...
        """,
        choices: [
            Choice(text: "Sprinkles", destination: 8),
            Choice(text: "Berries", destination: 7),
            Choice(text: "Chocolate", destination: 9),
        ]
    ),
    StoryPage( // 4
        """
        Okay, you seem to be in the clear. Everyone else is busy working on their cakes and you are too...only their cakes all look the same. You shrug it off thinking your cake is going to be unique and special. The judges come over and ask you what’s going to go on top of your cake. You tell them:
        """,
        choices: [
            Choice(text: "Piped buttercream frosting", destination: 13),
            Choice(text: "Fondant icing", destination: 7),
        ]
    ),
    StoryPage( // 5
        """
        The judges come around to ask you about your cake. You are so excited to tell them about your toppings, which include:
        """,
        choices: [
            Choice(text: "Marzipan figurines", destination: 12),
            Choice(text: "Fondant figurines", destination: 14),
        ]
    ),
    StoryPage( // 6
        """
        The judges come around to ask you what you are planning. You tell them all about your rainbow-themed cake. One of the things you tell them is:
        """,
        choices: [
            Choice(text: "It’s in the shape of a rainbow.", destination: 15),
            Choice(text: "It has multicolored candies on the outside.", destination: 10),
            Choice(text: "It has marshmallow clouds.", destination: 11),
        ]
    ),
    StoryPage( // 7
        """
        There is so much available to decorate with; your cake keeps getting better and better. You notice some of your neighbors are using some decorations you didn’t see on the table. What do you do?
        """,
        choices: [
            Choice(text: "Pop over to your neighbor’s bench and ask where they got their toppings.", destination: 16),
            Choice(text: "Keep working away, your cake will be great even without the specialty item.", destination: 20),
        ]
    ),
    StoryPage( // 8
        """
        You are running out of time, so you put your cake in the freezer to speed things up. You check it after ten minutes. What do you do?
        """,

        choices: [
            Choice(text: "Pipe the decorations onto parchment paper. You can transfer them at the last minute.", destination: 17),
            Choice(text: "Start decorating, you don’t have a minute to waste.", destination: 18),
        ]
    ),
    StoryPage( // 9
        """
        You are feeling good about your timing and start creating your design. You want your theme to come to life, so you decide to upgrade your flat design to become 3D. You head over to the table of supplies and grab:
        """,
        choices: [
            Choice(text: "Marzipan, and lots of it!", destination: 19),
            Choice(text: "Several bags of different color icing.", destination: 20),
        ]
    ),
    StoryPage( // 10
        """
        When you head to the decorations table to get some candy, you find red, orange, yellow, green, and purple candies, but no blue or indigo! Someone making a troll cake has used all the blue-colored candies. You come in a respectable 4th place, despite your five color rainbow.
        
        😢 Try again.
        """,
        choices: []
    ),
    StoryPage( // 11
        """
        The judges say you overbaked your cake and your marshmallow clouds fall flat. You come in 7th place.
        
        😢 Try again.
        """,
        choices: []
    ),
    StoryPage( // 12
        """
        Now that the judges have moved on, you are able to get going on your decorations. You start by making all the marzipan figurines. Things are looking pretty good until...you SNEEZE! 🤧🦠 The spray goes everywhere and you have to restart your decorations. In the end, you run out of time and come in 8th place.
        
        😢 Try again.
        """,
        choices: []
    ),
    StoryPage( // 13
        """
        The judges come by to ask about your cake. They keep prying for more, and can’t understand why you are only using piped icing. After a short time, you realize you have missed a HUGE part of the directions. You complete the challenge and go home in last place.
        
        😢 Try again.
        """,
        choices: []
    ),
    StoryPage( // 14
        """
        Your cake is coming together nicely when a dog bursts into the tent! He runs through, toppling over a tray of your piped decorations. You run out of time to remake them and come in 9th place.
        
        😢 Try again.
        """,

        choices: []
    ),
    StoryPage( // 15
        """
        Your rainbow turns out magnificent! The arch is sky high and it impresses the judges. You win 2nd place.🥈
        
        🎉💵You are a cash prize winner!💵🥳
        """,
        choices: []
    ),
    StoryPage( // 16
        """
        Right before you get to your neighbors bench, you trip on a power cord and knock over their sponge cake! There is no time for them to start over. They are devastated and you decide to disqualify yourself out of solidarity.
        
        😢 Try again.
        """,
        choices: []
    ),
    StoryPage( // 17
        """
        Good thinking! You can complete most of your decorations while your cake continues to cool. You work hard and, before you know it, the competition is over and your cake looks spectacular. You come in 3rd place!🥉
        
        🎉💵You are a cash prize winner!💵🥳
        """,
        choices: []
    ),
    StoryPage( // 18
        """
        Oh no, you should have waited! All your frosting melts off the sides and your decorations are a disaster. Luckily your cake perfectly baked. The judges are pretty harsh about your decorations and you finish in 6th place.
        
        😢 Try again.
        """,
        choices: []
    ),
    StoryPage( // 19
        """
        The judges love your theme and the 3D elements really make it come alive! They compliment you on your mix of chocolate and marzipan. You win 1st place! 🎉🎂🥇
        
        🎉💵You are a cash prize winner!💵🥳
        """,
        choices: []
    ),
    StoryPage( // 20
        """
        The judges compliment your theme, but the icing is too thick and overpowers the delicate cake. You come in 5th place.
        
        😢 Try again.
        """,
        choices: []
    ),
])
```

ここでは、定数`story`を初期化する際に `pages`パラメータに渡す「ページの配列」として、「21画面分の`StoryPage`型インスタンス」を作成しています。つまり、このアプリで語られる「物語の全て」を示す定数`story`が保持する`pages`配列は、「`0`から`20`まで」が有効なインデックスになります。

## StoryView.swift
アプリの外観を定義したファイルです。

```swift:StoryView.swift
import SwiftUI

struct StoryView: View {

    var body: some View {
        NavigationView {
            StoryPageView(story: story, pageIndex: 0)
        }
        .navigationViewStyle(.stack)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        StoryView()
    }
}
```

このビューは、`StoryPageView`型のインスタンス（一画面分の物語）をナビゲーション形式で辿っていけるように表示します。

## StoryPageView.swift
アプリの外観を定義したファイルです。

`story`プロパティは、アプリで語られる「物語の全て」を保持します。
`pageIndex`プロパティは、「物語の何ページ目を表示するか」を保持します。

```swift:StoryPageView.swift
import SwiftUI

struct StoryPageView: View {

    let story: Story
    let pageIndex: Int

    var body: some View {
        VStack {
            ScrollView {
                Text(story[pageIndex].text)
            }

            ForEach(story[pageIndex].choices, id: \Choice.text) { choice in
                NavigationLink(destination: StoryPageView(story: story, pageIndex: choice.destination)) {
                    Text(choice.text)
                        .multilineTextAlignment(.leading)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding()
                        .background(Color.gray.opacity(0.25))
                        .cornerRadius(8)
                }
            }
        }
        .padding()
        .navigationTitle("Page \(pageIndex + 1)")
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct NonlinearStory_Previews: PreviewProvider {
    static var previews: some View {
        StoryPageView(story: story, pageIndex: 0)
    }
}
```

`ForEach`コンテナは選択肢の配列に対して反復処理を行い、「タップ可能なリンク付きテキスト」として表示しています。