Staybnb M3 — iPad split view and edge functionsswift-6.4/ios-26
Lesson 4 / 6
Unit 27 · Staybnb

Staybnb M3 — iPad split view and edge functions

Note

Unit 27 · Staybnb Milestone 3. Two big pieces: the adaptive iPad layout, and your first server-side code — a Supabase edge function that does the heavy search.

This milestone makes Staybnb feel like a real iPad app and moves the expensive query to the server. NavigationSplitView gives you list-detail-map in one window that collapses cleanly to a stack on iPhone, and a Supabase edge function does the filtering-and-pricing so the device does not haul the whole catalog.

The three-column split view

NavigationSplitView has a three-column initializer: sidebar, content, detail. Map Staybnb's panes onto it and let a shared selection drive all three.

import SwiftUI

struct StaybnbSplit: View {
    @State private var model = SearchModel()
    @State private var selected: Listing.ID?

    var body: some View {
        NavigationSplitView {
            // Sidebar: filters
            FilterPanel(filter: $model.filter)
                .navigationTitle("Filters")
        } content: {
            // Content: the results list
            List(model.results, selection: $selected) { listing in
                ListingRow(listing: listing)
            }
            .navigationTitle("\(model.results.count) stays")
        } detail: {
            // Detail: selected listing + map
            if let id = selected, let listing = model.results.first(where: { $0.id == id }) {
                ListingDetail(listing: listing)
            } else {
                ListingsMap(listings: model.results, selected: $selected)
            }
        }
    }
}

One selected: Listing.ID? is the single source of truth. The list writes it (row selection), the map writes it (tapping a pin, from Milestone 1), and the detail column reads it. Because all three panes bind the same state, selecting in any one updates the others — no manual syncing.

Tip

Drive every pane of a split view from one shared selection value, not per-pane copies. A single selection binding is what makes "tap a pin, the list and detail follow" work for free. Two copies of the selection is how you get panes that drift out of sync.

Adapting to iPhone

The same NavigationSplitView collapses to a stack on iPhone automatically — the columns become pushes. You rarely need a size-class branch; author the split view once and let it adapt. Where you do want a different presentation (say, the map as a sheet on iPhone), read the horizontal size class and branch only that piece.

@Environment(\.horizontalSizeClass) private var sizeClass

var showsMapInline: Bool { sizeClass == .regular }   // iPad-ish

Reach for the size class as a refinement, not the primary structure. The split view already does the heavy lifting; over-branching on size class recreates the mess it exists to avoid.

Coming from older Swift

UIKit's answer was UISplitViewController with delegate methods to collapse and expand columns, plus manual work to keep the detail in sync when the primary changed. NavigationSplitView folds all of that into a declarative three-column view with a shared selection binding — the collapse behavior and the sync you used to write by hand are now the defaults.

The edge function

An edge function is server-side TypeScript deployed to Supabase, invoked over HTTPS. It runs close to the database, so filtering and pricing the catalog happens where the data lives — the device receives a small, ready-to-render page.

// supabase/functions/search/index.ts
import { createClient } from "jsr:@supabase/supabase-js@2";

Deno.serve(async (req) => {
  const { maxPrice, minGuests, amenities, nights } = await req.json();
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
  );

  let query = supabase
    .from("listings")
    .select("*")
    .lte("price_per_night", maxPrice)
    .gte("guests", minGuests)
    .limit(200);

  if (amenities?.length) query = query.contains("amenities", amenities);

  const { data, error } = await query;
  if (error) return new Response(error.message, { status: 500 });

  // Server-side pricing: total for the stay length.
  const priced = (data ?? []).map((l) => ({
    ...l,
    total_price: l.price_per_night * (nights ?? 1),
  }));

  return Response.json({ results: priced });
});

Deploy with supabase functions deploy search. The pricing lives here on purpose: one authoritative place computes totals, so the device never disagrees with the server about price, and you can change the pricing rule without shipping an app update.

Calling it from Swift

Invoke the function through the Supabase client and decode the result into your Listing model.

struct SearchResponse: Decodable, Sendable { let results: [Listing] }

func search(_ filter: Filter, nights: Int) async throws -> [Listing] {
    let body: [String: Any] = [
        "maxPrice": filter.maxPrice,
        "minGuests": filter.minGuests,
        "amenities": Array(filter.requiredAmenities),
        "nights": nights,
    ]
    let response: SearchResponse = try await supabase.functions
        .invoke("search", options: .init(body: body))
    return response.results
}

The app calls search, stores the returned page in the SearchModel from Milestone 2, and the device does last-mile filtering on that page as the user fine-tunes — the pure apply(to:) over a few hundred rows.

Note

The service-role key belongs only inside the edge function, never in the app bundle. The device calls the function with the user's anon/authenticated context; the elevated key stays server-side. Shipping a service key in an iOS binary is a full database compromise.

Checkpoint: your app should now…

  • Present list, detail, and map in a NavigationSplitView on iPad, driven by one selection.
  • Collapse gracefully to a navigation stack on iPhone with no separate code path.
  • Fetch results from a deployed search edge function that filters and prices server-side.

Stretch goals

  • Add cursor pagination to the edge function and infinite-scroll the results list.
  • Rank results in the function (price, then guest capacity) and expose a sort param.

Knowledge check

Q: How do the three panes stay in sync without manual wiring? They all bind the same selected: Listing.ID?. The list and map both write it and the detail reads it, so a selection in any pane updates the others. One shared source of truth replaces the delegate juggling UIKit's split controller required.

Q: Why compute pricing in the edge function instead of on the device? So one authoritative place owns the pricing rule: the device can never disagree with the server about a total, and the rule can change without an app release. It also keeps the payload small and the sensitive service-role key server-side.