SwiftUI

SwiftUI에서 Search Interface 추가하기

GREEN.1229 2024. 5. 17. 18:13

안녕하세요. 그린입니다 🍏

이번 포스팅에서는 SwiftUI에서 제공되는 메서드를 이용해 Search Interface를 추가하는 학습을 해보겠습니다 🙋🏻

 

우선 기본적으로 서치바를 구성하고 이 기능을 제공해보는거라고 보면 됩니다!

 

그럼 바로 한번 알아볼까요?

 


Search Interface?

앱에서 컨텐츠를 검색하는데 사용할 수 있는 서치 인터페이스를 SwiftUI에서 제공해줍니다.

SwiftUI에선 searchable이라는 뷰 모디파이어를 제공해줍니다.

간단히 이 뷰 모디파이어를 적용하여 아래와 같은 서치 인터페이스를 추가할 수 있습니다 😃

 

 

물론 이 searchable이라는 뷰 모디파이어의 이니셜라이저가 다양히 많아 적절한것을 적용시켜야 하죠.

보통은 기본적인 searchable 메서드는 iOS 15 이상에서부터 사용할 수 있지만, iOS 16, 17 이상에서만 사용되는 이니셜라이저들도 있으니 버전도 신경써줘야 합니다.

 

기본적으로, searchable에 바인딩 되는 값을 넘겨주어 검색을 하고 가져올 수 있습니다.

즉, 검색을 수행하는데에 사용하는 문자열에 대한 저장소를 제공해주죠.

 

간단히 먼저 코드를 한번 볼까요?

 

struct ContentView: View {
    @State private var departmentId: Department.ID?
    @State private var productId: Product.ID?
    @State private var searchText: String = ""


    var body: some View {
        NavigationSplitView {
            DepartmentList(departmentId: $departmentId)
        } content: {
            ProductList(departmentId: departmentId, productId: $productId)
        } detail: {
            ProductDetails(productId: productId)
        }
        .searchable(text: $searchText) // Adds a search field.
    }
}

 

우선 모델링 된 코드 부분을 제외한 공식문서의 코드입니다.

 

이렇게 macOS나 iPadOS에서 코드를 구성하고 searchable을 통해 String을 바인딩 시켜두고 뷰 모디파이어를 적용하면 아래와 같이 나타나요.

 

 

기본적으로 시스템이 인지하여 전체 NavigationSplitView에서 서치바를 띄워주는것이죠.

 

물론 이걸 구조적으로 제어할 수도 있습니다.

 

NavigationSplitView {
    DepartmentList(departmentId: $departmentId)
        .searchable(text: $searchText)
} content: {
    ProductList(departmentId: departmentId, productId: $productId)
} detail: {
    ProductDetails(productId: productId)
}

 

이렇게 DepartmentList에 붙여준다면, 해당 영역에서 서치바가 아래와 같이 나타납니다.

 

 

그게 아니더라도, searchable 뷰 모디파이어 적용 시 파라미터로 배치할 위치를 직접 코드로 줄 수도 있죠.

 

NavigationSplitView {
    DepartmentList(departmentId: $departmentId)
} content: {
    ProductList(departmentId: departmentId, productId: $productId)
} detail: {
    ProductDetails(productId: productId)
}
.searchable(text: $searchText, placement: .sidebar)

 

이렇게 사이드바에 서치바를 붙인다고 코드를 심어두면 아래와 같이 전체에서 뷰 모디파이어를 적용했어도, 사이드바에 나타나게 되죠.

 

 

여기서 알 수 있었던건, 이 서치바를 어디에 구조적이나 프로그래밍적으로 배치할 지 입맛대로 다뤄줄 수 있다는거였습니다!

 

또한, 기본적으로 서치 바 필드에 대해서 기본 플레이스 홀더 값을 넣어둘 수도 있어요.

어떤걸 어떻게 검색해야하는지 사전에 사용자에게 인지를 시켜줄 수 있는것이죠.

 

DepartmentList(departmentId: $departmentId)
    .searchable(text: $searchText, prompt: "Departments and products")

 

prompt 파라미터를 사용합니다.

 

 

이렇게 나타나겠죠!?

 

자 여기까지가 아주 단순히 이 서치 인터페이스가 무엇이고 SwiftUI에서 어떻게 배치되고 기본 기능이 무엇인지 살펴봤다면 실제로 예제를 하나 만들어보면서 Searchable을 익혀보고 서치 인터페이스 기능을 사용해볼까요?

 


Search Interface 본격 구현하기 🎮

우선 본격적으로 구현하기 전 가장 기본이 되는 searchable의 가장 기본적인 정의를 보고 가시죠!

 

func searchable(
    text: Binding<String>,
    placement: SearchFieldPlacement = .automatic,
    prompt: Text? = nil
) -> some View

 

이렇게 기본 형태는 서치 필드에 표시하고 편집할 텍스트인 String을 바인딩 변수로 받습니다.

그리고 뷰에서 어디에 이 서치 필드를 위치시킬지 placement 인자로 지정할 수 있죠.

(지정하지 않으면 기본값이 오토매틱이기에, 시스템이 적절한곳에 위치시킵니다.)

앞서 봤던 prompt 인자를 통해 검색 지침에 대해서 플레이스 홀더 값처럼 줄 수 있습니다.

 

이 외에도 인자로 토큰 등을 사용하거나 다양한 형태의 searchable 정의가 있으니 필요하신 부분을 둘러봐도 좋습니다!

 

그럼 진짜 구현해볼께요.

 

import SwiftUI

// Model
struct Animal: Identifiable {
  var id = UUID()
  var name: String
}

// View
struct ContentView: View {
  let animals: [Animal] = [
    .init(name: "dog"),
    .init(name: "cat"),
    .init(name: "cow"),
    .init(name: "bird"),
    .init(name: "pig"),
    .init(name: "monkey"),
    .init(name: "snake")
  ]
  @State private var searchText: String = ""
  var filteredAnimals: [Animal] {
    if searchText.isEmpty {
      return animals
    } else {
      return animals.filter { $0.name.contains(searchText.lowercased())
      }
    }
  }
  
  var body: some View {
    NavigationStack {
      List(filteredAnimals) { animal in
        Text(animal.name)
      }
    }
    .searchable(text: $searchText, prompt: "Search animals")
  }
}

 

간단한 코드가 있습니다.

동물이라는 타입의 구조체를 만들었어요.

그리고 ContentView에서 이 동물들을 리스트로 표현해주고 있습니다.

filteredAnimals라는 변수는 검색하고 있는 텍스트가 한글자라도 쳐졌을때 그에 해당하는 단어를 가진 동물만 리턴을 시켜주고 검색어가 한글자도 없으면 현재 전체 동물의 값을 리턴해주고 있습니다.

 

그리고 여기서 searchable 뷰 모디파이어를 이용해 searchText와 바인딩 시켜주고 서치 바 입력을 받도록 구성해줍니다.

 

여기서 가장 중요한거! 🙋🏻

 

searchable 뷰 모디파이어를 NavigationStack이나 NavigationView의 구성이 없이 그냥 List에만 붙일 경우 서치 바가 나타나지 않아요.

 

그 이유는 사용자 인터페이스의 일관성과 기대되는 동작 때문이라고 볼 수 있습니다.

searchable은 일반적으로 내비게이션 컨텍스트에서 사용되도록 설계되어 있기 때문입니다.

그렇기에 이 부분 유의하셔서 사용해야 합니다.

 

그럼 한번 돌려볼까요?

 

 

이렇게 검색한 데이터만 보여줄 수 있겠죠?

 

또 신기한 기능으로 searchSuggestions이라는 뷰 모디파이어가 존재합니다.

이 기능은 뷰에 대해서 검색을 제안하는것이에요.

 

func searchSuggestions<S>(@ViewBuilder _ suggestions: () -> S) -> some View where S : View

 

뷰빌더 클로저로 뷰를 받고 있죠?

 

코드로 한번 보시죠.

 

NavigationStack {
  List(filteredAnimals) { animal in
    Text(animal.name)
  }
  .searchable(text: $searchText, prompt: "Search animals")
  .searchSuggestions {
    Text("🐶").searchCompletion("dog")
    Text("🐱").searchCompletion("cat")
    Text("🐮").searchCompletion("cow")
    Text("🦜").searchCompletion("bird")
  }
}

 

이렇게 서치를 시작할때 제안 할 컨텐츠들을 보여주고 그 컨텐츠를 선택하면 어떤 단어로 변환할지 기능을 사용할 수 있습니다.

 

 

근데 사실 이 후에 처리가 더 필요하기도 하고 어떤 상황에서 이렇게 잘 쓰일지 감이 안오네요 🥲

 

그것보다 이게 더 도움이 될 수 있습니다!

 

struct ContentView: View {
  let animals: [Animal] = [
    .init(name: "dog"),
    .init(name: "cat"),
    .init(name: "cow"),
    .init(name: "bird"),
    .init(name: "pig"),
    .init(name: "monkey"),
    .init(name: "snake")
  ]
  @State private var searchText: String = ""
  
  var filteredAnimals: [Animal] {
    if searchText.isEmpty {
      return animals
    } else {
      return animals.filter { $0.name.contains(searchText.lowercased())
      }
    }
  }
  
  🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻
  var searchSuggestions: [Animal] {
    if searchText.isEmpty {
      return []
    } else {
      return animals.filter { $0.name.lowercased().contains(searchText.lowercased()) }
    }
  }
  
  var body: some View {
    NavigationStack {
      List(filteredAnimals) { animal in
        Text(animal.name)
      }
      🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻
      .searchable(text: $searchText, prompt: "Search animals") {
        ForEach(searchSuggestions) { suggestion in
          Text(suggestion.name)
            .searchCompletion(suggestion.name)
        }
      }
    }
  }
}

 

사람이 손들고 있는 부분의 코드를 기존 코드에서 추가했어요.

제안을 하는데 그 제안을 누르면 선택되고 이후 후속 컴플리션을 해주는것이죠.

 

즉, D를 입력한다면 알파벳 D가 포함된 Dog, Bird가 클릭할 수 있는 제안으로 나오고 하나를 선택하면 그 항목만 검색됩니다.

우리는 그렇기에 일일히 끝까지 다 완벽히 치지 않아도 쉽게 서치할 수 있죠.

어떻게 보면 자동 완성 기능으로 볼 수 있겠네요!

 

 

이렇게 알파벳 D를 입력하면 dog, bird가 추천으로 나오고 다 입력하지 않고 클릭하면 그 컨텐츠로 검색해주죠!

쉽지 않나요?ㅎㅎ

 

정말 마지막으로 검색 범위를 지정하는 기능도 있습니다 🙋🏻

 

 

바로, searchScopes 뷰 모디파이어를 활용하는것이에요!

 

func searchScopes<V, S>(
    _ scope: Binding<V>,
    @ViewBuilder scopes: () -> S
) -> some View where V : Hashable, S : View

 

scope를 통해 해당 범위에서 검색을 할 수 있도록 나눌 수 있어요.

다만, iOS 16 이상 사용이 가능합니다.

 

한번 코드 예시로 볼께요!

 

import SwiftUI

// Model
struct Animal: Identifiable {
  var id = UUID()
  var name: String
}

// View
struct ContentView: View {
  let animals: [Animal] = [
    .init(name: "dog"),
    .init(name: "cat"),
    .init(name: "cow"),
    .init(name: "bird"),
    .init(name: "pig"),
    .init(name: "monkey"),
    .init(name: "snake")
  ]
  
  @State private var searchText: String = ""
  🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻
  @State private var selectedScope: String = "All"
  var scopes = ["All", "Mammals", "Birds", "Reptiles"]
  
  var filteredAnimals: [Animal] {
    let filteredByScope = animalsFilteredByScope
    if searchText.isEmpty {
      return filteredByScope
    } else {
      return filteredByScope.filter { $0.name.contains(searchText.lowercased()) }
    }
  }
  
  🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻
  var animalsFilteredByScope: [Animal] {
    switch selectedScope {
    case "Mammals":
      return animals.filter { ["dog", "cat", "cow", "pig", "monkey"].contains($0.name) }
    case "Birds":
      return animals.filter { ["bird"].contains($0.name) }
    case "Reptiles":
      return animals.filter { ["snake"].contains($0.name) }
    default:
      return animals
    }
  }
  
  var body: some View {
    NavigationStack {
      List(filteredAnimals) { animal in
        Text(animal.name)
      }
      .searchable(text: $searchText, prompt: "Search animals")
      🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻🙋🏻
      .searchScopes($selectedScope) {
        ForEach(scopes, id: \.self) { scope in
          Text(scope)
        }
      }
    }
  }
}

 

기존 코드에서 추가된 사람이 손 들고 있는 부분만 보시죠!

 

먼저 State 상태 변수로 현재 선택된 스코프를 가지고 있고 사용할 스코프들 변수를 가집니다.

그리고 animalsFilteredByScope 변수에서 선택된 스코프에 따라 어떤 동물들만 검색 범위로 보여줄지 정합니다.

 

그리고 마지막으로, 뷰에서 searchScopes 뷰 모디파이어를 활용하면 되는데요.

이때 바인딩 변수로 selectedScope 즉, 현재 선택된 스코프를 넣어주는것이죠.

그럼 이제 지정한 모든 스코프들이 제공되고 현재 최초로는 All이 선택되있을거에요.

 

한번 돌려볼까요?

 

 

자 어떤가요!?

이렇게 검색 범위 스코프를 가지고 다양하게 활용해볼 수 있을것 같지 않나요ㅎㅎ

 

Search Interface를 구성하면서 필수적이라고 느껴지는 뷰 모디파이어들에 대해 알아봤습니다!

다른 뷰 모디파이어들도 있으니 관심 가져보세요 ☺️

 

참고로, 해당 예시 코드는 아래 깃헙 레포에서 자유롭게 보실 수 있습니다 🚀

 

 

playground/search at main · GREENOVER/playground

학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

github.com

 


마무리

이렇게 서치 인터페이스에 대해 알아보면서 searchable에 대해 파악해봤습니다!

물론, 이렇게 기본적으로 제공해주는걸 사용하지 않아도 충분히 바인딩을 통해 서치 바를 커스텀하게 구현해줄 수도 있습니다.

오히려 그게 더 디자인적으로 커스텀하게 가져가는것에 있어서 필요할때도 있죠 😀

그래도 늘 항상 생각드는것..!

알고 안쓰는게 좋다~ 라는 취지에서 학습해봤습니다 🚀

 


레퍼런스

 

Adding a search interface to your app | Apple Developer Documentation

Present an interface that people can use to search for content in your app.

developer.apple.com

 

searchable(text:placement:prompt:) | Apple Developer Documentation

Marks this view as searchable, which configures the display of a search field.

developer.apple.com

 

searchSuggestions(_:) | Apple Developer Documentation

Configures the search suggestions for this view.

developer.apple.com

 

searchScopes(_:scopes:) | Apple Developer Documentation

Configures the search scopes for this view.

developer.apple.com