Searchable vs. Textfield in a sheet deployed with presentationDetents

Let's start with a little background. There are various reasons why people use No Meat Today. Generally speaking, they want to reduce the amount of meat they eat, and the app allows you to track that in the simplest possible way: every day, you answer the question "Did you eat meat today?" with YES or NO.

You can define what you consider to be "meat" (red meat, poultry, fish, dairy…), but that's barely used in the app.

Still, there are many reasons why it'd be nice to log more details, for instance:

  • if you want to estimate your environmental footprint, it becomes important to distinguish beef from chicken or pork
  • if health is your concern, you will want to ensure that your nutritional requirements are met
  • if you're guided by ethics, maybe you'll be fine eating shrimp but limit the number of times you eat salmon

For these reasons, I want to offer users (myself included) a list of foods to quickly pick from when they add a meal.

This requires a picker, and, of course, I wanted the list to be searchable.

The UI I came up with will start from a simple row with 2 buttons: one for the quantity and one type of food. That's the "Some Red Meat" on the left in the screenshot below.

With .searchable()

When you tap on the "Red Meat" button, a sheet appears with a .presentationDetents([.height(200), .large]).

With .searchable()

I started with searchable(), which requires a NavigationSplitView or NavigationStack (or NavigationView).

It works beautifully: if you enter the Search field, the sheet expands to the whole screen and "Cancel" brings it back to the smallest size.

But, because it is using a NavigationStack, there is a huge empty space above the search field. That's a lot of wasted space for such a use case.

Fixing the extra top space

I tried to play with simple things such as .toolbar(.hidden, for: .navigationBar, .tabBar) or .navigationTitle("")

The only thing I could think of to fix the extra space is to set a negative padding when the search field is not focused.

For that, I needed to access the isSearching @Environment variable. But this is passed down to the environment by the NavigationView, which means that you can't use it in the View that includes the NavigationView.

So I broke down my FoodPicker into two views, and I used a binding to let the FoodPicker be aware of the isSearching value.

Next, I experimented with the padding and found the magic number: -48.

struct FoodPicker: View {
    @State private var selectedConsumable: Consumable?
    
    @State private var searchText = ""
    
    @State private var isSearching = false
    
    var body: some View {
        NavigationView {
            SearchableConsumables(
                selectedConsumable: $selectedConsumable,
                searchText: $searchText,
                isSearchingBinding: $isSearching
            )
        }
        .padding(.top, isSearching ? 0 : -48)
        .searchable(text: $searchText)
    }
}

struct SearchableConsumables: View {
    @Environment(\.isSearching) private var isSearching

    @Binding var selectedConsumable: Consumable?
    
    @Binding var searchText: String
    
    @Binding var isSearchingBinding: Bool

    var body: some View {
        Group {
            if searchText.isEmpty {
                FoodGrid(selectedConsumable: $selectedConsumable)
            } else {
                FilteredFoodGrid(
                    selectedConsumable: $selectedConsumable,
                    searchText: $searchText
                )
            }
        }
        .onChange(of: isSearching) { newValue in
            withAnimation {
                isSearchingBinding = newValue
            }
        }
    }
}

In case you're wondering about the FoodGridvs FilteredFoodGrid, this is just because I don't want to show section headers during a search as they overwhelm the UI.

The result was satisfying:

Except for one more thing, which is that the search field collapses when the search is canceled.

For this, I had two ideas:

  • do nothing and just accept it
  • or, since the minimum size is a bit too small anyway, use medium instead of .height(200)for the detent and add placement: .navigationBarDrawer(displayMode: .always) to searchable

With a Textfield()

I'm not a fan of magic numbers, so I wanted to see if I could do without the -48, and I tried another approach with a good old Textfield.

The code looked like this:

struct FoodPicker: View {
    @State private var selectedConsumable: Consumable?
    
    @State private var searchText = ""
    
    @FocusState private var searchIsFocused: Bool
    
    var body: some View {
        VStack {
            HStack {
                TextField(
                    "Search",
                    text: $searchText,
                    prompt: Text("\(Image(systemName: "magnifyingglass")) Search")
                )
                .focused($searchIsFocused)
                .padding(8)
                .background(
                    RoundedRectangle(cornerRadius: 8)
                        .fill(Color(.secondarySystemBackground))
                )
                
                Spacer()
                if searchIsFocused {
                    Button("cancel") {
                        searchIsFocused = false
                        dismissSearch()
                    }
                }
            }
            .padding()
            if searchText.isEmpty {
                FoodGrid(selectedConsumable: $selectedConsumable)
            } else {
                FilteredFoodGrid(
                    selectedConsumable: $selectedConsumable,
                    searchText: $searchText
                )
            }
        }
    }
}

I just love that you can mix SF Symbols inside a Text. It feels like magic to me.

The result is similar to the searchable() approach.

With a custom Textfield

The downsides to this approach are:

  • the Preview is messed up: it always expands to the larger size
  • it doesn't support all the fancy search features, such as tokens and scopes, but I don't need them here
  • the cursor starts above the magnifying glass, but it disappears as you type, so I can live with that

Conclusion

I think the Textfield is probably good enough. Maybe I'll change my mind when I work on the watchOS, iPadOS, or macOS version, but for now, that'll do!

The last thing I realized is that providing 2 sizes for the detent when you have scrollable content is not ideal: you can't scroll right away; you first need to expand the sheet.

So I changed that and kept only the medium size: