【SwiftUI】Food Truckのサンプルコードを読んでみた

# はじめに

`ObservableObject`の扱い方を理解したく、こちらのFood Truckという公式のサンプルコードを読みました。

https://developer.apple.com/documentation/swiftui/food_truck_building_a_swiftui_multiplatform_app

# 環境

Xcode 14.0


# 内容

<img width="100" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/0242d893-3a1c-7f9e-df61-e1e07d017dd9.png"> <img width="100" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/77989a63-8056-fa51-013e-23226b178e88.png"> <img width="100" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/f1f9e217-afec-246f-8e1e-d1e694b36f69.png"> <img width="100" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/09d5ec0f-e638-1d9e-2325-034dcaf88167.png">

メニュー画面 -> トラック画面 -> オーダー画面　-> オーダー完了画面
のサンプルコードから`ObservableObject`の扱い方を整理してみました。

※ サンプルコードに一部修正を加えながらまとめております（公開されているサンプルコードと比べると異なる部分があります）

## メニュー画面

<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/0242d893-3a1c-7f9e-df61-e1e07d017dd9.png">

```swift: FoodTruckApp
@main
struct FoodTruckApp: App {
    /// The app's state.
    @StateObject private var model = FoodTruckModel()
    /// The in-app purchase store's state.
    @StateObject private var accountStore = AccountStore()
    var body: some Scene {
        WindowGroup {
            ContentView(model: model, accountStore: accountStore)
        }
    }
}

```


```swift: ContentView
struct ContentView: View {
    @ObservedObject var model: FoodTruckModel
    @ObservedObject var accountStore: AccountStore
    
    @State private var selection: Panel? = Panel.truck
    @State private var path = NavigationPath()

    @Environment(\.displayStoreKitMessage) private var displayStoreMessage
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        NavigationSplitView {
            Sidebar(selection: $selection)
        } detail: {
            NavigationStack(path: $path) {
                DetailColumn(selection: $selection, model: model)
            }
        }
        .onChange(of: selection) { _ in
            path.removeLast(path.count)
        }
        .environmentObject(accountStore)!
        .onChange(of: scenePhase) { newValue in
            if newValue == .active {
                StoreMessagesManager.shared.displayAction = displayStoreMessage
            }
        }
        .onPreferenceChange(StoreMessagesDeferredPreferenceKey.self) { newValue in
            StoreMessagesManager.shared.sensitiveViewIsPresented = newValue
        }
    }
}
```

<details><summary>Panel</summary>

```swift: Panel
enum Panel: Hashable {
    case truck
    case socialFeed
    case orders
    case salesHistory
    case donuts
    case donutEditor
    case topFive
    case city(City.ID)
}
```

</details>

<details><summary>Sidebar</summary>

```swift: Sidebar
struct Sidebar: View {
    @Binding var selection: Panel?

    var body: some View {
        List(selection: $selection) {
            NavigationLink(value: Panel.truck) {
                Label("Truck", systemImage: "box.truck")
            }
            NavigationLink(value: Panel.orders) {
                Label("Orders", systemImage: "shippingbox")
            }            
            NavigationLink(value: Panel.socialFeed) {
                Label("Social Feed", systemImage: "text.bubble")
            }
            NavigationLink(value: Panel.salesHistory) {
                Label("Sales History", systemImage: "clock")
            }            
            Section("Donuts") {
                NavigationLink(value: Panel.donuts) {
                    Label {
                        Text("Donuts")
                    } icon: {
                        Image.donutSymbol
                    }
                }                
                NavigationLink(value: Panel.donutEditor) {
                    Label("Donut Editor", systemImage: "slider.horizontal.3")
                }                
                NavigationLink(value: Panel.topFive) {
                    Label("Top 5", systemImage: "trophy")
                }
            }
            Section("Cities") {
                ForEach(City.all) { city in
                    NavigationLink(value: Panel.city(city.id)) {
                        Label(city.name, systemImage: "building.2")
                    }
                    .listItemTint(.secondary)
                }
            }
        }
        .navigationTitle("Food Truck")
    }
}
```

</details>

<details><summary>DetailColumn</summary>

```swift: DetailColumn
struct DetailColumn: View {
    @Binding var selection: Panel?
    @ObservedObject var model: FoodTruckModel
    @State var timeframe: Timeframe = .today
    
    var body: some View {
        switch selection ?? .truck {
        case .truck:
            TruckView(model: model, navigationSelection: $selection)
        case .orders:
            OrdersView(model: model)
        case .socialFeed:
            SocialFeedView()
        case .salesHistory:
            SalesHistoryView(model: model)
        case .donuts:
            DonutGallery(model: model)
        case .donutEditor:
            DonutEditor(donut: $model.newDonut)
        case .topFive:
            TopFiveDonutsView(model: model)
        case .city(let id):
            CityView(city: City.identified(by: id))
        }
    }
}
```

</details>

<details><summary>FoodTruckModel</summary>

```swift: FoodTruckModel
import SwiftUI

@MainActor
public class FoodTruckModel: ObservableObject {
    @Published public var truck = Truck()
    
    @Published public var orders: [Order] = []
    @Published public var donuts = Donut.all
    @Published public var newDonut: Donut
    
    var dailyOrderSummaries: [City.ID: [OrderSummary]] = [:]
    var monthlyOrderSummaries: [City.ID: [OrderSummary]] = [:]

    public init() {
        newDonut = Donut(
            id: Donut.all.count,
            name: String(localized: "New Donut", comment: "New donut-placeholder name."),
            dough: .plain,
            glaze: .chocolate,
            topping: .sprinkles
        )
        
        let orderGenerator = OrderGenerator(knownDonuts: donuts)
        orders = orderGenerator.todaysOrders()
        dailyOrderSummaries = Dictionary(uniqueKeysWithValues: City.all.map { city in
            (key: city.id, value: orderGenerator.historicalDailyOrders(since: .now, cityID: city.id))
        })
        monthlyOrderSummaries = Dictionary(uniqueKeysWithValues: City.all.map { city in
            (key: city.id, orderGenerator.historicalMonthlyOrders(since: .now, cityID: city.id))
        })
        Task(priority: .background) {
            var generator = OrderGenerator.SeededRandomGenerator(seed: 5)
            for _ in 0..<20 {
                try? await Task.sleep(nanoseconds: .secondsToNanoseconds(.random(in: 3 ... 8, using: &generator)))
                Task { @MainActor in
                    withAnimation(.spring(response: 0.4, dampingFraction: 1)) {
                        self.orders.append(orderGenerator.generateOrder(number: orders.count + 1, date: .now, generator: &generator))
                    }
                }
            }
        }
    }
    
    public func dailyOrderSummaries(cityID: City.ID) -> [OrderSummary] {
        guard let result = dailyOrderSummaries[cityID] else {
            fatalError("Unknown City ID or daily order summaries were not found for: \(cityID).")
        }
        return result
    }
    
    public func monthlyOrderSummaries(cityID: City.ID) -> [OrderSummary] {
        guard let result = monthlyOrderSummaries[cityID] else {
            fatalError("Unknown City ID or monthly order summaries were not found for: \(cityID).")
        }
        return result
    }
    
    public func donuts<S: Sequence>(fromIDs ids: S) -> [Donut] where S.Element == Donut.ID {
        ids.map { donut(id: $0) }
    }
    
    public func donutSales(timeframe: Timeframe) -> [DonutSales] {
        combinedOrderSummary(timeframe: timeframe).sales.map { (id, count) in
            DonutSales(donut: donut(id: id), sales: count)
        }
            
    }
    
    public func donuts(sortedBy sort: DonutSortOrder = .popularity(.month)) -> [Donut] {
        switch sort {
        case .popularity(let timeframe):
            return donutsSortedByPopularity(timeframe: timeframe)
        case .name:
            return donuts.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
        case .flavor(let flavor):
            return donuts.sorted {
                $0.flavors[flavor] > $1.flavors[flavor]
            }
        }
    }
    
    public func orderBinding(for id: Order.ID) -> Binding<Order> {
        Binding<Order> {
            guard let index = self.orders.firstIndex(where: { $0.id == id }) else {
                fatalError()
            }
            return self.orders[index]
        } set: { newValue in
            guard let index = self.orders.firstIndex(where: { $0.id == id }) else {
                fatalError()
            }
            return self.orders[index] = newValue
        }
    }
    
    public func orderSummaries(for cityID: City.ID, timeframe: Timeframe) -> [OrderSummary] {
        switch timeframe {
        case .today:
            return orders.map { OrderSummary(sales: $0.sales) }
            
        case .week:
            return Array(dailyOrderSummaries(cityID: cityID).prefix(7))
            
        case .month:
            return Array(dailyOrderSummaries(cityID: cityID).prefix(30))
            
        case .year:
            return monthlyOrderSummaries(cityID: cityID)
        }
    }
    
    public func combinedOrderSummary(timeframe: Timeframe) -> OrderSummary {
        switch timeframe {
        case .today:
            return orders.reduce(into: .empty) { partialResult, order in
                partialResult.formUnion(order)
            }
            
        case .week:
            return dailyOrderSummaries.values.reduce(into: .empty) { partialResult, summaries in
                summaries.prefix(7).forEach { day in
                    partialResult.formUnion(day)
                }
            }
            
        case .month:
            return dailyOrderSummaries.values.reduce(into: .empty) { partialResult, summaries in
                summaries.prefix(30).forEach { day in
                    partialResult.formUnion(day)
                }
            }
            
        case .year:
            return monthlyOrderSummaries.values.reduce(into: .empty) { partialResult, summaries in
                summaries.forEach { month in
                    partialResult.formUnion(month)
                }
            }
        }
    }
    
    func donutsSortedByPopularity(timeframe: Timeframe) -> [Donut] {
        let result = combinedOrderSummary(timeframe: timeframe).sales
            .sorted {
                if $0.value > $1.value {
                    return true
                } else if $0.value == $1.value {
                    return $0.key < $1.key
                } else {
                    return false
                }
            }
            .map {
                donut(id: $0.key)
            }
        return result
    }
    
    public func donut(id: Donut.ID) -> Donut {
        donuts[id]
    }
    
    public func donutBinding(id: Donut.ID) -> Binding<Donut> {
        Binding<Donut> {
            self.donuts[id]
        } set: { newValue in
            self.donuts[id] = newValue
        }
    }
    
    public func updateDonut(id: Donut.ID, to newValue: Donut) {
        donutBinding(id: id).wrappedValue = newValue
    }
    
    public func markOrderAsCompleted(id: Order.ID) {
        guard let index = orders.firstIndex(where: { $0.id == id }) else {
            return
        }
        orders[index].status = .completed
    }
    
    public var incompleteOrders: [Order] {
        orders.filter { $0.status != .completed }
    }
}

public enum DonutSortOrder: Hashable {
    case popularity(Timeframe)
    case name
    case flavor(Flavor)
}

public enum Timeframe: String, Hashable, CaseIterable, Sendable {
    case today
    case week
    case month
    case year
}

public extension FoodTruckModel {
    static let preview = FoodTruckModel()
}

```

</details>


#### `ObservableObject`の流れ

FoodTruckApp：　`@StateObject`で`FoodTruckModel`、`AccountStore`を保持
👇
ContentView：　`@ObservedObject`で`FoodTruckModel`、`AccountStore`を保持
👇
NavigationSplitView：　`.environmentObject()`で`AccountStore`を渡す
DetailColumn：　　`@ObservedObject`で`FoodTruckModel`を保持
👇
TruckView: `@ObservedObject`で`FoodTruckModel`を保持


## トラック画面

<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/77989a63-8056-fa51-013e-23226b178e88.png"><img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/0fc33be1-29a0-90a3-3b8a-ed01e4c86f7a.png">


```swift: TruckView
struct TruckView: View {
    @ObservedObject var model: FoodTruckModel
    @Binding var navigationSelection: Panel?

    var body: some View {
        ScrollView(.vertical) {
            VStack(spacing: 16) {
                BrandHeader()
                VStack(spacing: 12) {
                    TruckOrdersCard(model: model)
                    TruckWeatherCard(location: model.truck.location.location)
                    TruckDonutsCard(donuts: model.donuts)
                    TruckSocialFeedCard()
                }
                .containerShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
                .fixedSize(horizontal: false, vertical: true)
                .padding([.horizontal, .bottom], 16)
            }
        }
        .background(Color(uiColor: .systemGroupedBackground))
        .background()
        .navigationTitle("Truck")
        .navigationDestination(for: Panel.self) { panel in
            switch panel {
            case .orders:
                OrdersView(model: model)
            case .donuts:
                DonutGallery(model: model)
            case .socialFeed:
                SocialFeedView()
            case .city(let id):
                CityView(city: City.identified(by: id))
            default:
                DonutGallery(model: model)
            }
        }
    }
}
```

#### `ObservableObject`からの`Order`データの流れ

TruckView： `@ObservedObject`で`FoodTruckModel`を保持
👇
TruckOrdersCard： `@ObservedObject`で`FoodTruckModel`を保持 -> `var orders: [Order] { model.orders }`にてmodelからordersを取得

## トラック画面のオーダーセル

<img width="300" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/a0a9343b-35d6-e77d-1789-c735117eaa43.png">


```swift
struct TruckOrdersCard: View {
    @ObservedObject var model: FoodTruckModel
    var orders: [Order] { model.orders }
    
    @State private var pulseOrderText = false
    
    var body: some View {
        VStack(alignment: .leading) {
            CardNavigationHeader(panel: .orders) {
                Label("New Orders", systemImage: "shippingbox")
            }

            (HeroSquareTilingLayout()) {
                ForEach(orders.reversed().prefix(5)) { order in
                    let iconShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
                    DonutStackView(donuts: order.donuts)
                        .padding(6)
                        .background(Color(uiColor: .tertiarySystemFill), in: iconShape)
                        .overlay {
                            iconShape.strokeBorder(.quaternary, lineWidth: 0.5)
                        }
                        .transition(
                            .asymmetric(
                                insertion: .offset(x: -100).combined(with: .opacity),
                                removal: .scale.combined(with: .opacity)
                            )
                        )
                }
                .aspectRatio(1, contentMode: .fit)
            }
            .aspectRatio(2, contentMode: .fit)
            .frame(maxWidth: .infinity, maxHeight: 250)
            
            if let order = orders.last {
                HStack {
                    Text(order.id)
                    Image.donutSymbol
                    Text(order.totalSales.formatted())
                }
                .frame(maxWidth: .infinity)
                .foregroundStyle(pulseOrderText ? .primary : .secondary)
                .fontWeight(pulseOrderText ? .bold : .regular)
                .contentTransition(.interpolate)
            }
        }
        .padding(10)
        .clipShape(ContainerRelativeShape())
        .background()
        .onChange(of: orders.last) { newValue in
            Task(priority: .background) {
                try await Task.sleep(nanoseconds: .secondsToNanoseconds(0.1))
                Task { @MainActor in
                    withAnimation(.spring(response: 0.25, dampingFraction: 1)) {
                        pulseOrderText = true
                    }
                }
                try await Task.sleep(nanoseconds: .secondsToNanoseconds(1))
                Task { @MainActor in
                    withAnimation(.spring(response: 0.25, dampingFraction: 1)) {
                        pulseOrderText = false
                    }
                }
            }
        }
    }
}
```

```swift: CardNavigationHeader
struct CardNavigationHeader<Label: View>: View {
    var panel: Panel
    @ViewBuilder var label: Label
    
    var body: some View {
        HStack {
            NavigationLink(value: panel) {
                label
            }
            Spacer()
        }
        .buttonStyle(.borderless)
        .labelStyle(.cardNavigationHeader)
    }
}
```

```swift: DonutStackView
public struct DonutStackView: View {
    var donuts: [Donut]
    var includeOverflowCount: Bool
    
    public init(donuts: [Donut], includeOverflowCount: Bool = false) {
        self.donuts = donuts
        self.includeOverflowCount = includeOverflowCount
    }

    public var body: some View {
        DiagonalDonutStackLayout {
            ForEach(donuts.prefix(3)) { donut in
                DonutView(donut: donut)
            }
        }
        .overlay(alignment: .bottomTrailing) {
            let extra = donuts.count - 3
            if extra > 0 && includeOverflowCount {
                Text("+\(extra)")
                    .foregroundStyle(.secondary)
                    .font(.caption2)
                    .padding(4)
                    .background(.thinMaterial, in: Capsule())
            }
        }
    }
}
```

<details><summary>Order</summary>

```swift: Order
public struct Order: Identifiable, Equatable {
    public var id: String
    
    // order
    public var status: OrderStatus
    public var donuts: [Donut]
    public var sales: [Donut.ID: Int]
    public var grandTotal: Decimal
    
    // location
    public var city: City.ID
    public var parkingSpot: ParkingSpot.ID
    
    // metadata
    public var creationDate: Date
    public var completionDate: Date?
    public var temperature: Measurement<UnitTemperature>
    public var wasRaining: Bool
    
    public init(
        id: String,
        status: OrderStatus,
        donuts: [Donut],
        sales: [Donut.ID: Int],
        grandTotal: Decimal,
        city: City.ID,
        parkingSpot: ParkingSpot.ID,
        creationDate: Date,
        completionDate: Date?,
        temperature: Measurement<UnitTemperature>,
        wasRaining: Bool
    ) {
        self.id = id
        self.status = status
        self.donuts = donuts
        self.sales = sales
        self.grandTotal = grandTotal
        self.city = city
        self.parkingSpot = parkingSpot
        self.creationDate = creationDate
        self.completionDate = completionDate
        self.temperature = temperature
        self.wasRaining = wasRaining
    }
    
    public var duration: TimeInterval? {
        guard let completionDate = completionDate else {
            return nil
        }
        return completionDate.timeIntervalSince(creationDate)
    }
    
    public var totalSales: Int {
        sales.map(\.value).reduce(0, +)
    }
    
    public mutating func markAsComplete() {
        if case .completed = status {
            return
        }
        self.completionDate = .now
        self.status = .completed
    }
    
    public var isComplete: Bool {
        if case .completed = status {
            return true
        }
        return false
    }
    
    public var formattedDate: String {
        let date: String = {
            if Calendar.current.isDateInToday(creationDate) {
                return String(localized: "Today")
            } else if Calendar.current.isDateInYesterday(creationDate) {
                return String(localized: "Yesterday")
            } else {
                return creationDate.formatted(date: .numeric, time: .omitted)
            }
        }()
        let time = creationDate.formatted(date: .omitted, time: .shortened)
        return "\(date), \(time)"
    }
    
    public func matches(searchText: String) -> Bool {
        if id.localizedCaseInsensitiveContains(searchText) {
            return true
        }
        // Search donuts...
        return false
    }
}
```

</Details>

<details><summary>HeroSquareTilingLayout</summary>

```swift
struct HeroSquareTilingLayout: Layout {
    var spacing: Double
    
    init() {
        self.spacing = 10
    }
    
    init(spacing: Double = 10) {
        self.spacing = spacing
    }
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
        let size = proposal.replacingUnspecifiedDimensions(by: CGSize(width: 100, height: 100))
        let heroLength = min((size.width - spacing) * 0.5, size.height)
        let boundsWidth = heroLength * 2 + spacing
        return CGSize(width: boundsWidth, height: heroLength)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
        let heroLength = min((bounds.width - spacing) * 0.5, bounds.height)
        let boundsWidth = heroLength * 2 + spacing
        let halfRectSize = CGSize(width: heroLength, height: heroLength)
        let heroOrigin = CGPoint(
            x: bounds.origin.x + ((bounds.width - boundsWidth) * 0.5),
            y: bounds.origin.y + ((bounds.height - heroLength) * 0.5)
        )
        let tileLength = (heroLength - 10) * 0.5
        
        let tilesOrigin = CGPoint(x: heroOrigin.x + heroLength + spacing, y: heroOrigin.y)
        let tilesRect = CGRect(origin: tilesOrigin, size: halfRectSize)
        
        for index in subviews.indices {
            if index == 0 {
                // hero cell
                subviews[index].place(
                    at: heroOrigin,
                    anchor: .topLeading,
                    proposal: ProposedViewSize(halfRectSize)
                )
            } else {
                // smaller tile
                let tileIndex = index - 1
                let xPos: Double = tileIndex % 2 == 0 ? 0 : 1
                let yPos: Double = tileIndex < 2 ? 0 : 1
                let point = CGPoint(
                    x: tilesRect.minX + (xPos * tilesRect.width),
                    y: tilesRect.minY + (yPos * tilesRect.height)
                )
                subviews[index].place(
                    at: point,
                    anchor: UnitPoint(x: xPos, y: yPos),
                    proposal: ProposedViewSize(width: tileLength, height: tileLength)
                )
            }
        }
    }
}

```

</details>

#### `ObservableObject`からの`Order`データの流れ

TruckOrdersCard： `@ObservedObject`で`FoodTruckModel`を保持 -> `var orders: [Order] { model.orders }`にてmodelからordersを取得
👇
DonutStackView: initの`order.donuts`を`var donuts: [Donut]`にて保持


## オーダー画面

<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/f1f9e217-afec-246f-8e1e-d1e694b36f69.png">

```swift: OrdersView
struct OrdersView: View {
    @ObservedObject var model: FoodTruckModel
    
    @State private var sortOrder = [KeyPathComparator(\Order.status, order: .reverse)]
    @State private var searchText = ""
    
    var orders: [Order] {
        model.orders.filter { order in
            order.matches(searchText: searchText) || order.donuts.contains(where: { $0.matches(searchText: searchText) })
        }
        .sorted(using: sortOrder)
    }
    
    var body: some View {
        ZStack {
            List(orders) { order in
                NavigationLink(value: order.id) {
                    OrderRow(order: order)
                        .badge(order.totalSales)
                }
            }
        }
        .navigationTitle("Orders")
        .navigationDestination(for: Order.ID.self) { id in
            OrderDetailView(order: model.orderBinding(for: id))
        }
        .searchable(text: $searchText)
    }
}
```

```swift: OrderRow
struct OrderRow: View {
    var order: Order
    
    var body: some View {
        HStack {
            let iconShape = RoundedRectangle(cornerRadius: 4, style: .continuous)
            DonutStackView(donuts: order.donuts)
                .padding(2)
                .frame(width: 40, height: 40)
                .background(in: iconShape)
                .overlay {
                    iconShape.strokeBorder(.quaternary, lineWidth: 0.5)
                }
            Text(order.id)
        }
    }
}

```

#### `ObservableObject`からの`Order`データの流れ

TruckView： `@ObservedObject`で`FoodTruckModel`を保持
👇
OrdersView： `@ObservedObject`で`FoodTruckModel`を保持 
-> `var orders: [Order] { model.orders.filter{} }`にてmodelからordersを取得
👇
OrderRow: `var order: Order`にて`order`を保持

## オーダー詳細画面

 <img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/ebeef130-ae47-e129-b9a8-f3ee7753a72b.png">


```swift: OrderDetailView
struct OrderDetailView: View {
    @Binding var order: Order
    
    @State private var presentingCompletionSheet = false
    
    var body: some View {
        List {
            Section("Status") {
                HStack {
                    Text(order.status.title)
                    Spacer()
                    Image(systemName: order.status.iconSystemName)
                        .foregroundColor(.secondary)
                }
                
                HStack {
                    Text("Order Started")
                    Spacer()
                    Text(order.formattedDate)
                        .foregroundColor(.secondary)
                }
            }
            
            Section("Donuts") {
                ForEach(order.donuts) { donut in
                    Label {
                        Text(donut.name)
                    } icon: {
                        DonutView(donut: donut)
                    }
                    .badge(order.sales[donut.id]!)
                }
            }
            
            Text("Total Donuts")
                .badge(order.totalSales)
        }
        .navigationTitle(order.id)
        .sheet(isPresented: $presentingCompletionSheet) {
            OrderCompleteView(order: order)
        }
        .onChange(of: order.status) { status in
            if status == .completed {
                presentingCompletionSheet = true
            }
        }
        .toolbar {
            ToolbarItemGroup {
                Button {
                    order.markAsComplete()
                } label: {
                    Label("Complete Order", systemImage: "checkmark.circle")
                        .symbolVariant(order.isComplete ? .fill : .none)
                }
                .labelStyle(.iconOnly)
                .disabled(order.isComplete)
            }
        }
    }
}
```

#### `ObservableObject`からの`Order`データの流れ

OrderView： `@ObservedObject`で`FoodTruckModel`を保持 
-> `var orders: [Order] { model.orders.filter{} }`にてmodelからordersを取得
-> `OrderDetailView(order: model.orderBinding(for: id))`で値を渡す
👇
OrderDetailView: `@Binding`で`var order: Order`を保持

## オーダー完了画面

<img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/09d5ec0f-e638-1d9e-2325-034dcaf88167.png"> <img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/775e6d87-c83c-2ea8-c913-f83a13d5e2c0.png"> <img width="200" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/107fda03-14d7-2e58-76fc-1da4263ddbb1.png">


```swift: 
struct OrderCompleteView: View {
    @Environment(\.requestReview) private var requestReview
    @AppStorage("versionPromptedForReview") private var versionPromptedForReview: String?
    @AppStorage("datePromptedForReview") private var datePromptedForReview: TimeInterval?
    
    @State private var boxClosed = false
    @State private var boxBounce = false
    
    var order: Order
    
    @Environment(\.dismiss) private var dismiss
    
    var currentAppVersion: String? {
        Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
    }
    
    var body: some View {
        NavigationStack {
            VStack {
                DonutBoxView(isOpen: boxClosed) {
                    DonutView(donut: order.donuts.first!)
                }
                .offset(y: boxBounce ? 15 : 0)
                .aspectRatio(1, contentMode: .fit)
                .frame(maxWidth: 300)
                .onTapGesture {
                    Task { @MainActor in
                        await toggleBoxAnimation()
                    }
                }
                
                Text("\(order.id) completed!")
                    .font(.headline)
                HStack(spacing: 4) {
                    Text("\(order.totalSales.formatted()) donuts")
                    Text("•")
                    Text(Date.now.formatted(date: .omitted, time: .shortened))
                }
                .foregroundStyle(.secondary)
            }
            .padding()
            .task { @MainActor in
                try? await Task.sleep(nanoseconds: .secondsToNanoseconds(0.75))
                await toggleBoxAnimation()
                // Wait 1.5 seconds before displaying.
                try? await Task.sleep(nanoseconds: .secondsToNanoseconds(1.5))
                if shouldRequestReview {
                    requestReview()
                    didRequestReview()
                }
            }
            .navigationTitle(order.id)
            .toolbar {
                ToolbarItemGroup(placement: .confirmationAction) {
                    Button("Done") {
                        dismiss()
                    }
                }
            }
        }
    }
    
    func toggleBoxAnimation() async {
        // Animate the box closing with a slow spring without bounce.
        withAnimation(.spring(response: 0.35, dampingFraction: 1)) {
            boxClosed.toggle()
        }
        if boxClosed {
            try? await Task.sleep(nanoseconds: .secondsToNanoseconds(0.15))
            // Move the box downward with a spring around the time it closes.
            withAnimation(.spring()) {
                boxBounce = true
            }
            try? await Task.sleep(nanoseconds: .secondsToNanoseconds(0.15))
            // Move the box back with a spring to complete the look.
            withAnimation(.spring()) {
                boxBounce = false
            }
        }
    }
    
    var shouldRequestReview: Bool {
        guard
            let versionPromptedForReview = versionPromptedForReview,
            let datePromptedForReview = datePromptedForReview
        else {
            // If we've never asked the user to review, we'll prompt for a review.
            return true
        }
        guard let currentAppVersion = self.currentAppVersion else {
            return false
        }
        
        let dateLastAsked = Date(timeIntervalSince1970: datePromptedForReview)
        
        var dateComponent = DateComponents()
        dateComponent.month = 4
        
        guard let fourMonthsLater = Calendar.current.date(byAdding: dateComponent, to: dateLastAsked) else {
            return false
        }
        
        return fourMonthsLater < .now && versionPromptedForReview != currentAppVersion
    }
    
    func didRequestReview() {
        versionPromptedForReview = self.currentAppVersion
        datePromptedForReview = Date().timeIntervalSince1970
    }
}
```

<details><summary>DonutBoxView</summary>

```swift: DonutBoxView
public struct DonutBoxView<Content: View>: View {
    var isOpen: Bool
    var content: Content
    
    public init(isOpen: Bool, @ViewBuilder content: () -> Content) {
        self.isOpen = isOpen
        self.content = content()
    }
    
    public var body: some View {
        GeometryReader { proxy in
            let length = min(proxy.size.width, proxy.size.height)
            ZStack {
                Image("box/Inside", bundle: .module)
                    .resizable()
                    .scaledToFit()
                
                content
                    .padding(floor(length * 0.15))
                    .frame(width: length, height: length)
                
                Image("box/Bottom", bundle: .module)
                    .resizable()
                    .scaledToFit()
                
                if isOpen {
                    Image("box/Lid", bundle: .module)
                        .resizable()
                        .scaledToFit()
                        .transition(
                            .scale(scale: 0.75, anchor: .bottom)
                                .combined(with: .offset(y: -length * 0.5))
                                .combined(with: .opacity)
                        )
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}
```

</details>


<details><summary>DonutView</summary>

```swift:　DonutView
public let donutThumbnailSize: Double = 128

public struct DonutView: View {
    var donut: Donut
    var visibleLayers: DonutLayer = .all

    public init(donut: Donut, visibleLayers: DonutLayer = .all) {
        self.donut = donut
        self.visibleLayers = visibleLayers
    }
    
    public var body: some View {
        GeometryReader { proxy in
            let useThumbnail = min(proxy.size.width, proxy.size.height) <= donutThumbnailSize

            ZStack {
                if visibleLayers.contains(.dough) {
                    donut.dough.image(thumbnail: useThumbnail)
                        .resizable()
                        .interpolation(.medium)
                        .scaledToFit()
                        .id(donut.dough.id)
                }

                if let glaze = donut.glaze, visibleLayers.contains(.glaze) {
                    glaze.image(thumbnail: useThumbnail)
                        .resizable()
                        .interpolation(.medium)
                        .scaledToFit()
                        .id(glaze.id)
                }

                if let topping = donut.topping, visibleLayers.contains(.topping) {
                    topping.image(thumbnail: useThumbnail)
                        .resizable()
                        .interpolation(.medium)
                        .scaledToFit()
                        .id(topping.id)
                }
            }
            .aspectRatio(1, contentMode: .fit)
            .compositingGroup()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}

public struct DonutLayer: OptionSet {
    public var rawValue: Int
    
    public init(rawValue: Int) {
        self.rawValue = rawValue
    }
    
    public static let dough = DonutLayer(rawValue: 1 << 1)
    public static let glaze = DonutLayer(rawValue: 1 << 2)
    public static let topping = DonutLayer(rawValue: 1 << 3)
    
    public static let all: DonutLayer = [dough, glaze, topping]
}
```

</details>

#### `Order`データの流れ

OrderDetailView: `@Binding`で`var order: Order`を保持
-> `OrderCompleteView(order: order)` で値を渡す
👇
OrderCompleteView: `var order: Order`を保持


### FoodTruckModel(ObservableObject)について

`@Published`の`orders: [Order]`から特定の値を`Binding<Order>`として値を渡せるメソッドが定義されている

```swift: FoodTruckModel抜粋
@MainActor
public class FoodTruckModel: ObservableObject {
    @Published public var orders: [Order] = []

    public func orderBinding(for id: Order.ID) -> Binding<Order> {
        Binding<Order> {
            guard let index = self.orders.firstIndex(where: { $0.id == id }) else {
                fatalError()
            }
            return self.orders[index]
        } set: { newValue in
            guard let index = self.orders.firstIndex(where: { $0.id == id }) else {
                fatalError()
            }
            return self.orders[index] = newValue
        }
    }
}
```
`OrderRow(order: order)`へ渡す因数は`var orders: [Order]`の値で良いが
`OrderDetailView(order: model.orderBinding(for: id))`では`@Binding`の値を渡す必要があるため、`model`から値を渡している

```swift: OrdersView抜粋
struct OrdersView: View {
    @ObservedObject var model: FoodTruckModel
    
    var orders: [Order] {
        model.orders.filter { ... }
    }
    
    var body: some View {
        ZStack {
            List(orders) { order in
                NavigationLink(value: order.id) {
                    OrderRow(order: order)
                }
            }
        }
        .navigationTitle("Orders")
        .navigationDestination(for: Order.ID.self) { id in
            OrderDetailView(order: model.orderBinding(for: id))
        }
    }
}
```

OrderDetailViewでは、`order.markAsComplete()`にて`Order`の状態を変更するので`@Binding`となっている

```swift: OrderDetailView抜粋
struct OrderDetailView: View {
    @Binding var order: Order
    
    @State private var presentingCompletionSheet = false
    
    var body: some View {
        List {
            ...
        }
        .navigationTitle(order.id)
        .sheet(isPresented: $presentingCompletionSheet) {
            OrderCompleteView(order: order)
        }
        .onChange(of: order.status) { status in
            if status == .completed {
                presentingCompletionSheet = true
            }
        }
        .toolbar {
            ToolbarItemGroup {
                Button {
                    order.markAsComplete()
                } label: {
                    Label("Complete Order", systemImage: "checkmark.circle")
                        .symbolVariant(order.isComplete ? .fill : .none)
                }
                .labelStyle(.iconOnly)
                .disabled(order.isComplete)
            }
        }
    }
}
```

```swift: Order抜粋
public struct Order: Identifiable, Equatable {
    public var id: String
    public var status: OrderStatus
    public var completionDate: Date?
    
    public mutating func markAsComplete() {
        if case .completed = status {
            return
        }
        self.completionDate = .now
        self.status = .completed
    }
    
    public var isComplete: Bool {
        if case .completed = status {
            return true
        }
        return false
    }
}
```

OrderRowでは値を表示するだけなので、`@Binding`ではなく`var order: Order`となっている

```swift: OrderRow抜粋
struct OrderRow: View {
    var order: Order
    
    var body: some View {
        HStack {
            Text(order.id)
        }
    }
}
```

# おわりに

表現間違っている部分もあるかもしれませんが、ざっくりデータの流れが理解できました。
このサンプルコードからは学べることが多いですが、一気に理解しようとすると混乱するだけなので、テーマを分けて理解を深めていこうと思います


# 参考

https://developer.apple.com/documentation/swiftui/food_truck_building_a_swiftui_multiplatform_app