알파벳 내비게이터 만들기

아래와 같이 알파벳으로 섹션이 나뉘어져 있으며 해당 알파벳을 클릭하면 섹션으로 이동하는 기능을 알파벳 내비게이터라고 칭하겠습니다. (정식 명칭은 다를 수 있습니다.)

Animated GIF - Find & Share on GIPHY

 

출처

 

기본 형태

  • Contacts배열에 있는 사람 목록을 보여주는 뷰입니다.
    • 예제를 복잡하지 않게 하기 위해 단순 [String] 배열로 만들었습니다.
  • 아래 기본 형태를 바탕으로 진행합니다.
import SwiftUI

struct ContentView: View {
  @State private var searchText = ""
  var contacts = [String]()

  var body: some View {
    ScrollViewReader { scrollProxy in
        List {
          ForEach(contacts, id: \.self) { contact in
            HStack {
              Image(systemName: "person.circle.fill")
                .font(.largeTitle)
                .padding(.trailing, 5)
              Text(contact)
            }
          }
        }
        .navigationTitle("Contacts")
        .listStyle(PlainListStyle())
      }
  }
  
  init() {
    contacts = [
      "Chris", "Ryan", "Allyson", "Ryan", "Jonathan", "Ryan", "Brendan", "Ryaan",
      "Jaxon", "Riner", "Leif", "Adams", "Frank", "Conors", "Allyssa", "Bishop",
      "Justin", "Bishop", "Johnny", "Appleseed", "George", "Washingotn", "Abraham", "Lincoln",
      "Steve", "Jobs",  "Steve", "Woz", "Bill", "Gates", "Donald", "Trump", "Darth", "Vader",
      "Clark", "Kent", "Bruce", "Wayne", "John", "Doe",  "Jane", "Doe", "Rei", "Kim",
      "James", "Elephant", "Julius", "Fucik", "Kane", "Hammersmith",
    ]
    contacts.sort()
  }
}

 

사람 이름을 알파벳 섹션으로 분류하기

Step 1: 전역 변수로 알파벳 배열을 추가합니다.
let alphabet: [String] = (65...90).map { String(UnicodeScalar($0)!) }
  • 65는 대문자 A의 아스키 코드이며, 90은 대문자 Z의 아스키 코드입니다.
  • A부터 Z까지의 아스키 코드 범위에서 아스키 코드를 실제 문자로 변환해서 배열로 저장합니다.

 

Step 2: 알파벳 첫문자로 Contacts 배열 필터링하기
func contactsFilter(by letter: String) -> [String] {
  contacts.filter { $0.prefix(1) == letter }
}
  • contact의 첫문자(prefix(1))가 letter(알파벳 문자)와 일치할 경우만 필터링합니다.

 

Step 3: 알파벳 섹션 만들기

List {...} 안에 다음을 추가합니다.

ForEach(alphabet, id: \.self) { letter in
  Section(header: Text(letter).id(letter)) {
    
  }
}
  • ForEach를 통해 알파벳 문자마다 섹션을 만들고, 헤더 텍스트와 id를 해당 알파벳으로 지정합니다.
  • id는 나중에 알파벳 내비게이터에서 버튼을 눌렀을 때 스크롤 위치를 지정하기 위한 역할입니다.

 

Step 4: 섹션별로 필터링된 사람 목록 보여주기

Section {...} 안에 사람 목록을 보여주는 ForEach문을 넣되, 대상 자료를 필터링된 ContactscontactsFilter(by:)로 바꿔줍니다.

ForEach(alphabet, id: \.self) { letter in
  Section(header: Text(letter).id(letter)) {
    ForEach(contactsFilter(by: letter), id: \.self) { contact in
      HStack {
        Image(systemName: "person.circle.fill")
          .font(.largeTitle)
          .padding(.trailing, 5)
        Text(contact)
      }
    }
  }
  • letter에 따라 필터링된 목록을 보여줍니다.

 

 

알파벳 내비게이터 추가: 탭 방식

알파벳을 누르면 해당 섹션으로 이동하는 기초적인 내비게이터를 추가하겠습니다. ScrollView {...}의 오버레이를 추가합니다.

.overlay(alignment: .top) {
  VStack {
    ForEach(alphabet, id: \.self) { letter in
      HStack {
        Spacer()
        Button {
          withAnimation {
            scrollProxy.scrollTo(letter, anchor: .top)
          }
        } label: {
          Text(letter)
            .font(.system(size: 15))
            .padding(.trailing, 7)
        }
      }
    }
  }
}
  • overlay(alignment: .top) {...} – 오버레이를 추가하며, 오버레이 뷰가 top을 기준으로 정렬됩니다.
    • alignment를 추가하지 않으면 스크롤 뷰의 한가운데에 위치하게 됩니다.
  • HStackSpacer()를 줘서 내비게이터가 화면 오른쪽으로 붙어있도록 합니다.
  • Button의 첫 번째 트레일링 클로저(action)에 scrollTo(아이디) 명령을 추가해 버튼을 누르면 해당 섹션 타이틀로 이동하도록 합니다.
    • anchor.top이어야 헤더가 눈에 보이는 제일 위에 위치하게 됩니다.
    • withAnimation으로 감싸면 스크롤 애니메이션이 되면서 자연스럽게 이동하고, 사용하지 않으면 애니메이션 없이 바로 이동합니다.

Animated GIF - Find & Share on GIPHY

탭(클릭)하면 해당 알파벳 헤더로 이동합니다.

 

알파벳 내비게이터 추가: 탭 + 드래그 방식

위에 예제도 바로 사용가능하긴 하지만, 기존에 알던 알파벳 내비게이터는 드래그로도 선택할 수 있고, 진동도 울렸던 것으로 기억합니다.

 

Step 1: 뷰 분리
.overlay(alignment: .top) {
  AlphabetNavigator(scrollViewProxy: scrollProxy)
}
  • 위 버튼 예제에서 overlay 안의 컨텐츠를 위와 같이 바꾸고 별도의 struct로 분리합니다.
  • View를 준수하는 AlphabetNavigator 구조체입니다.
  • ScrollViewProxyScrollViewReader의 프록시를 넘겨줍니다.

 

Step 2: AlphabetNavigator 뷰 구현
struct AlphabetNavigator: View {
  let scrollViewProxy: ScrollViewProxy
  
  @GestureState private var dragLocation: CGPoint = .zero
  @State private var currentLetter = ""
  
  func dragObserver(title: String) -> some View {
    GeometryReader { geometry in
      dragObserver(geometry: geometry, title: title)
    }
  }

  func dragObserver(geometry: GeometryProxy, title: String) -> some View {
    if geometry.frame(in: .global).contains(dragLocation) {
      DispatchQueue.main.async {
        currentLetter = title
        
        withAnimation {
          scrollViewProxy.scrollTo(title, anchor: .top)
        }
      }
    }
    
    return Rectangle().fill(.clear)
  }
  
  var body: some View {
    VStack {
      ForEach(alphabet, id: \.self) { letter in
        HStack {
          Spacer()
          Text(letter)
            .font(.system(size: 18, weight: .semibold))
            .foregroundStyle(.cyan)
            .padding(.trailing, 7)
            .opacity(letter == currentLetter ? 0.3 : 1)
          .background(dragObserver(title: letter))
        }
      }
    }
    .gesture(
      DragGesture(minimumDistance: 0, coordinateSpace: .global)
        .updating($dragLocation) { value, state, _ in
          state = value.location
        }
    )
    .onChange(of: currentLetter) { _ in
      if currentLetter != "" {
        Vibration.light.vibrate()
        
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
          withAnimation {
            currentLetter = ""
          }
        }
      }
    }
  }
}
  • scrollViewProxy: 스크롤 뷰 이동기능을 위한 프록시입니다.
  • dragLocation: @GestureState를 사용해 현재 드래그중인 영역을 저장합니다.
  • currentLetter: 현재 선택한 ID를 저장합니다. 선택된 알파벳을 서식 처리하고 진동을 울리기 위해 필요합니다.
  • dragObserver: GeometryReader를 사용해 현재 드래그 위치가 리더가 제공하는 해당 영역에 있다면 스크롤 이동 명령을 실행합니다.
  • Text(letter) ... : 액션을 외부 함수에서 실행하므로 버튼을 제거하고 텍스트만 남겨둡니다.
  • .opacity(letter == currentLetter ? 0.3 : 1) : 현재 선택중이라면 불투명도를 낮춥니다(=> 더 투명해집니다.)
  • .background(dragObserver(title: letter)) : 배경으로 현재 위치에 있는 알파벳을 dragObserver로 넘깁니다.
    • GeometryReader를 배경에 배치한 것과 동일합니다.
  • onChange: 현재 선택된 알파벳에 따라 진동을 울리고, 2초 뒤에 선택을 해제해서 계속 투명하게 보이지 않도록 합니다.

 

Animated GIF - Find & Share on GIPHY

탭뿐만 아니라 드래그로도 이동할 수 있습니다. 실제 기기라면 진동도 울립니다.

 

전체 코드


import SwiftUI
let alphabet: [String] = (65…90).map { String(UnicodeScalar($0)!) }
struct ContentView: View {
@State private var searchText = ""
var contacts = [
"Chris", "Ryan", "Allyson", "Ryan", "Jonathan", "Ryan", "Brendan", "Ryaan", "Jaxon", "Riner", "Leif", "Adams", "Frank", "Conors", "Allyssa", "Bishop", "Justin", "Bishop", "Johnny", "Appleseed", "George", "Washingotn", "Abraham", "Lincoln", "Steve", "Jobs", "Steve", "Woz", "Bill", "Gates", "Donald", "Trump", "Darth", "Vader", "Clark", "Kent", "Bruce", "Wayne", "John", "Doe", "Jane", "Doe", "Rei", "Kim", "James", "Elephant", "Julius", "Fucik", "Kane", "Hammersmith",
].sorted()
func contactsFilter(by letter: String) -> [String] {
contacts.filter { $0.prefix(1) == letter }
}
var body: some View {
ScrollViewReader { scrollProxy in
List {
ForEach(alphabet, id: \.self) { letter in
Section(header: Text(letter).id(letter)) {
ForEach(contactsFilter(by: letter), id: \.self) { contact in
HStack {
Image(systemName: "person.circle.fill")
.font(.largeTitle)
.padding(.trailing, 5)
Text(contact)
}
}
}
}
}
.navigationTitle("Contacts")
.listStyle(PlainListStyle())
.overlay(alignment: .top) {
AlphabetNavigator(scrollViewProxy: scrollProxy)
}
}
}
}
struct AlphabetNavigator: View {
let scrollViewProxy: ScrollViewProxy
@GestureState private var dragLocation: CGPoint = .zero
@State private var currentLetter = ""
func dragObserver(title: String) -> some View {
GeometryReader { geometry in
dragObserver(geometry: geometry, title: title)
}
}
func dragObserver(geometry: GeometryProxy, title: String) -> some View {
if geometry.frame(in: .global).contains(dragLocation) {
DispatchQueue.main.async {
currentLetter = title
withAnimation {
scrollViewProxy.scrollTo(title, anchor: .top)
}
}
}
return Rectangle().fill(.clear)
}
var body: some View {
VStack {
ForEach(alphabet, id: \.self) { letter in
HStack {
Spacer()
Text(letter)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.cyan)
.padding(.trailing, 7)
.opacity(letter == currentLetter ? 0.3 : 1)
.background(dragObserver(title: letter))
}
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragLocation) { value, state, _ in
state = value.location
}
)
.onChange(of: currentLetter) { _ in
if currentLetter != "" {
Vibration.light.vibrate()
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
withAnimation {
currentLetter = ""
}
}
}
}
}
}

문의 | 코멘트 또는 yoonbumtae@gmail.com


카테고리: Swift


0개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다