Touch-friendly range selection UI

How can you let people easily select multiple items on a touchscreen?

It’d be nice to not need to tap or swipe over each item, and remember, no Shift or Command keys.

I can’t believe we still don’t have a convention for this, seventeen years after the iPhone.

Band selection is inadequate

Yes, iOS lets you swipe to quickly select in lists and grids, but you still have to smear your finger over each item.

That’s like dragging a box on a computer. It’s better than nothing, but you don’t want to select 1000 items that way.

Let’s call that band selection. I want range selection: “1, 1000, and everything in between”.

And it must be discoverable.

My design

Here’s what I came up with:

  1. If an item is unselected, include “Select Range Above” and “Select Range Below”. If it’s selected, include “Deselect” in each direction.
  2. “Select Range Above” selects this item and each item above it, until it reaches the top or an already-selected item.
  3. Disable “Above” if there’s no item above, or the item above has the opposite state from this item. This is important. If “Above” works but doesn’t do anything meaningful, you sit there confused wondering what it’s for.
  4. Likewise for “Below”, except the opposite direction.
  5. Likewise for “Deselect”, except the opposite action.

It’s simple yet powerful.

Please copy it! If you’re a product manager looking for a UI to use in your app, you can stop reading now. My source code is below.

But for enthusiasts, here’s my thinking.

Failed approach

At first, I imitated Shift-click, which selects the range from your most-recently selected item.

Hence “Select Range From Previous”, which points up if you last selected an item above, and down otherwise.

So if you select A then G, then try a range from C, the range should point down.

But it’s not so simple. What if you then deselect G? The range from C should point up again, right?

Apparently this design needs to remember the order in which you selected each item, which is a lot of hidden state.

And deselection is confusing. What if you select A–G, then want to deselect A–C? The range from C depends on how you selected A–G.

At this point, I realized I don’t fully understand Shift- and Command-click, and I reckon you don’t either. Quiz time! What happens if you do this?

  1. Click A.
  2. Shift-click G.
  3. Command-click C.

Good so far? Now Shift-click A.

Did you expect that? I didn’t.

Elegant approach

My touch-friendly design avoids all these problems.

There’s no hidden state or order effects; you can always see what’ll happen, without ever considering what you did earlier. The resulting clarity is incredible.

Imagine using this UI to select hundreds of images among thousands, or key moments in a long video.

With Shift- and Command-click, you’d give up, because you’re always one misclick away from messing up a range or deselecting everything; it’s a breath-holding operation. So those UIs sometimes give you helper tools like marking, filtering, and splitting.

But with touch-friendly ranges, I could spend hours selecting clips in a movie, and never worry about losing my place.

A shortcoming

One problem is that most of my testers initially thought ranges always reached the top or bottom of the list. They were surprised when they first saw a range stop at the end of a block.

So some people will stick with subpar usage: if they selected T–Z and want to add L–P, they’ll think they need to …

  1. On L, “Select Range Below”, temporarily selecting Q–S.
  2. On Q, “Deselect Range Below”, losing the original selection.
  3. On T, “Select Range Below”, restoring the original selection.

Hopefully you realize it’d be better to …

  1. Select L.
  2. On P, “Select Range Above”.

(… or select P, then on L, “Select Range Below”.)

“Well, if people think the range always reaches the end, why not just make it do that?” That subpar usage is why.

“Select to End” is only a little simpler, yet an order of magnitude less powerful. Intuitiveness is important, but not the top priority.

Maybe a simple wording change like “region” would communicate ranges better, but I haven’t had luck. If anyone can invent a better design, I’d love to know.

Regardless, even with subpar usage, touch-friendly ranges are still more useful than the statuses quo, which are band selection or Shift- and Command-click.

Please copy my design

I once whinged to a senior programmer with good taste that to delete ten songs on my iPhone, I had to swipe ten times. I thought that was stupid when I was a kid using an iPod Touch in 2009. Yet I assumed we’d surely figure out range selection on touchscreens within a few years. But here we are with iOS 18.

“So make something better, and share it with the world.”

There.

Please copy and improve my design if you think it’s a good idea. Any UI that lets you select multiple items should let you select ranges! In fact, this touch-friendly design also works on computers. In five years, when I want to move multiple emails, contacts, or bookmarks, I don’t want to have to tap or swipe over each one.

Just like you deserve games that inspire curiosity, you deserve computers that help you think great ideas. A simple, powerful way to handle multiple items is an important part of a bicycle for the mind.


struct RangeList: View {
  var body: some View {
    NavigationStack {
      List(0 ..< Self.rows.count, id: \.self) { index in
        let isSelected = selected.contains(index)
        HStack {
          if isSelected {
            Image(systemName: "checkmark.circle.fill")
              .foregroundStyle(.tint)
          } else {
            Image(systemName: "circle")
              .foregroundStyle(.secondary)
          }
          Text(String(Self.rows[index]))
          Spacer()
          Menu {
            aboveButton(index, isSelected)
            belowButton(index, isSelected)
          } label: {
            Image(systemName: "ellipsis.circle.fill")
              .symbolRenderingMode(.hierarchical)
          }
        }
        .contentShape(Rectangle()) // Makes spacer tappable.
        .onTapGesture {
          if isSelected {
            selected.remove(index)
          } else {
            selected.insert(index)
          }
        }
      }.toolbar {
        ToolbarItem(placement: .topBarLeading) {
          Button("Clear") { selected = [] }
        }
      }
    }
  }
  @State private var selected: Set<Int> = []
  private static let rows: [Character] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map { $0 }

  @ViewBuilder private func aboveButton(
    _ index: Int, _ isSelected: Bool
  ) -> some View {
    Button {
      var inRange = index
      if isSelected {
        while
          Self.rows.indices.contains(inRange),
          selected.contains(inRange)
        {
          selected.remove(inRange)
          inRange -= 1
        }
      } else {
        while
          Self.rows.indices.contains(inRange),
          !selected.contains(inRange)
        {
          selected.insert(inRange)
          inRange -= 1
        }
      }
    } label: {
      Label(
        isSelected ? "Deselect Range Above" : "Select Range Above",
        systemImage: "chevron.up.circle")
    }.disabled({
      let above = index - 1
      guard Self.rows.indices.contains(above) else { return true }
      return selected.contains(above) != isSelected
    }())
  }
  @ViewBuilder private func belowButton(
    _ index: Int, _ isSelected: Bool
  ) -> some View {
    Button {
      var inRange = index
      if isSelected {
        while
          Self.rows.indices.contains(inRange),
          selected.contains(inRange)
        {
          selected.remove(inRange)
          inRange += 1
        }
      } else {
        while
          Self.rows.indices.contains(inRange),
          !selected.contains(inRange)
        {
          selected.insert(inRange)
          inRange += 1
        }
      }
    } label: {
      Label(
        isSelected ? "Deselect Range Below" : "Select Range Below",
        systemImage: "chevron.down.circle")
    }.disabled({
      let below = index + 1
      guard Self.rows.indices.contains(below) else { return true }
      return selected.contains(below) != isSelected
    }())
  }
}