Staybnb M3 — iPad split view and edge functions
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.
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.
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.
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
NavigationSplitViewon iPad, driven by one selection. - Collapse gracefully to a navigation stack on iPhone with no separate code path.
- Fetch results from a deployed
searchedge 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.