본문 바로가기
💻 Programming 개발/🍎 iOS 개발, Swift

[SwiftUI] Focus 에 관하여 2 - WWDC 2023 영상 정리

by 킴디 kimdee 2023. 10. 1.
반응형

 

이 글은 아래 WWDC2023 영상을 참고하여 정리하였습니다.

 

https://developer.apple.com/wwdc23/10162

 

The SwiftUI cookbook for focus - WWDC23 - Videos - Apple Developer

The SwiftUI team is back in the coding

developer.apple.com

 

 

 

 

포커스 API와 관련 요소들은 아래 1편에서 정리되어 있습니다.

 

https://kimdee.tistory.com/entry/SwiftUI-Focus-%EC%97%90-%EA%B4%80%ED%95%98%EC%97%AC-1-WWDC-2023-%EC%98%81%EC%83%81-%EC%A0%95%EB%A6%AC

 

[SwiftUI] Focus 에 관하여 1 - WWDC 2023 영상 정리

시작하며 SwiftUI 를 졸업프로젝트에 적용해보고 나서, 좀 더 깊이있게, 기본기를 탄탄히 공부해야겠다는 결심만 한 지 어언 5개월이 지났고, 개인사와 회사일에 휩쓸리며 살다가 이제야 정신차리

kimdee.tistory.com

 


Focus를 컨트롤하기

 

WWDC 2022에도 나왔던 쿡북앱을 이용해서 포커스 컨트롤에 대해 설명을 이어갈 예정.

 

장보기 리스트 시트 뷰가 보이면, 끝에는 항상 이렇게 빈 아이템이 보이게 되는데, 저 빈 아이템을 클릭하게 되면 키보드가 올라오고, 사야될 아이템을 작성할 수 있게 한다. 식료품을 장보기 리스트에 추가하는 건 자주 하게 될테니까 일일히 탭하는 게 아니라, 언제든 장보기 리스트가 뜨면 자동으로 빈 아이템에 포커스가 가게 하고 싶을 것.

 

1편에서 포커스 상태 API로 @FocusState를 이용해 어느 뷰에서 포커스를 가지고 있는지 관측하고 업데이트 하도록 했는데, 같은 API를 이용할 것이다. 

 

 

기본 포커스

 

일전 예시에는 단일 뷰가 포커스를 가지고 있는지에 대한 플래그를 사용했는데, 이 장보기 리스트 화면에서는 관측해야 되는 텍스트필드는 계속 늘어날 수 있다. 이 경우에 @FocusState 값은 Hashable 한 값을 사용할 수 있다.

 

장보기에 추가되는 이런 아이템들은 각각 고유의 ID를 가지게 된다. 이 ID를 저장하면서 계속 포커스를 추적할 수 있다.

 

focused(_:equals:) 모디파이어를 이용해 포커스된 아이템과 아이템의 아이디 사이에 링크를 만들 수 있다.

이제 앱을 실행하고, 장보기 리스트를 탭했을 때 focusedItem 프로퍼티가 다른 ID값으로 업데이트 되는지 검증해볼 것.

 

 

포커스 바인딩이 제대로 되어 있으면, 장보기 리스트가 화면에 처음으로 나타날 때, 프로그래밍으로 포커스를 텍스트필드로 이동하기 위해서 필요한 게 있음. 그걸 하기 위해서 defaultFocus(::) 모디파이어를 리스트에 추가하게 될 것. 참고로 이 모디파이어는 iOS 17부터 사용이 가능.

 

시스템이 화면에서 처음으로 포커스를 계산할 때, 이 모디파이어가 장보기 리스트의 가장 마지막 아이템으로 바인딩을 업데이트 하게 됨.

 

 

포커스를 프로그래밍적으로 이동시키기

그렇지만 + 버튼을 눌러서 빈 아이템이 생겨도 여전히 포커스가 기존에 있던 곳에 그대로 남아있게 되는 문제가 생김. 프로그래밍적으로 포커스를 옮기고자 하는 또 다른 케이스. 새 아이템이 생기자마자 바로 타이핑을 하도록 하고 싶음.

 

두 가지 케이스의 다른 점은, 이제 포커스가 변하는 타이밍을 컨트롤하고 싶다는 점.

 

이전에 defaultFocus(::) 에도 사용한 FocusState 바인딩을 똑같이 사용할 수 있다.

 

GroceryListView 에서 빈 아이템을 추가하는 addEmptyItem 메서드를 추가할 것. 기존에 ID 프로퍼티가 포커스와 연관되어 있으므로 새 아이템의 아이디를 바로 포커스된 아이템의 프로퍼티로 넣어준다.

 

private func addEmptyItem() {
    let newItem = list.addItem()
    focusedItem = newItem.id
}

 

이제 그냥 입력만 하면 바로 되는 것을 볼 수 있다.

 

 

 

- 포커스 컨트롤에 대한 전체 코드

 

struct GroceryListView: View {
    @State private var list = GroceryList.examples
    @FocusState private var focusedItem: GroceryList.Item.ID? //Hashable한값이

    var body: some View {
        NavigationStack {
            List($list.items) { $item in
                HStack {
                    Toggle("Obtained", isOn: $item.isObtained)
                    TextField("Item Name", text: $item.name)
                        .onSubmit { addEmptyItem() }
                        .focused($focusedItem, equals: item.id)
                }
            }
            .defaultFocus($focusedItem, list.items.last?.id)
            .toggleStyle(.checklist)
        }
        .toolbar {
            Button(action: addEmptyItem) {
                Label("New Item", systemImage: "plus")
            }
        }
    }

    private func addEmptyItem() {
        let newItem = list.addItem()
        focusedItem = newItem.id
    }
}

struct GroceryList: Codable {
    static let examples = GroceryList(items: [
        GroceryList.Item(name: "Apples"),
        GroceryList.Item(name: "Lasagna"),
        GroceryList.Item(name: "")
    ])

    struct Item: Codable, Hashable, Identifiable {
        var id = UUID()
        var name: String
        var isObtained: Bool = false
    }

    var items: [Item] = []

    mutating func addItem() -> Item {
        let item = GroceryList.Item(name: "")
        items.append(item)
        return item
    }
}

struct ChecklistToggleStyle: ToggleStyle {
    func makeBody(configuration: Configuration) -> some View {
        Button {
            configuration.isOn.toggle()
        } label: {
            Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle.dashed")
                .foregroundStyle(configuration.isOn ? .green : .gray)
                .font(.system(size: 20))
                .contentTransition(.symbolEffect)
                .animation(.linear, value: configuration.isOn)
        }
        .buttonStyle(.plain)
        .contentShape(.circle)
    }
}

extension ToggleStyle where Self == ChecklistToggleStyle {
    static var checklist: ChecklistToggleStyle { .init() }
}

 

 

 


포커스를 좀더 정교하게 다루기

 

이제 좀 더 포커스 인터랙션을 좀 더 정교하게 발전시키도록 해볼 것.

 

 

이모지를 탭할 수 있도록 추가해서 각 레시피를 평가할 수 있도록 해볼 것.

 

키보드 네비게이션을 선호하는 사람으로서, Tab키를 이용해 컨트롤 위에 포커스가 가능하도록 하고, 화살표 키를 이용해서 선택을 변경할 수 있게 할 것.

 

 

이모지를 이용한 컨트롤을 만들 때 제일 처음 먼저해야되는 것은 컨트롤을 포커스 가능하도록 .focusable() 모디파이어를 넣어주어야된다. 일단은 인자 없이 사용. 이 모디파이어로 Tab키를 눌렀을 때 컨트롤이 포커스가 가능해짐.

 

 

다른 버튼과 유사한 컨트롤들과 다른 추가적인 동작이 보이는데 이 이모지 컨트롤은 클릭하게 되면 포커스가 가게 됨. 버튼과 세그먼트 컨트롤은 이렇게 포커스가 가질 않음.. 이 컨트롤에 포커스가 가기 위해서는 키보드 내비게이션이 필요함. 포커스로 활성화가 되는 컨트롤은 클릭이 될 때 포커스가 가지 않음. 또한 키보드 내비게이션이 켜져있어야 키보드로부터 포커스를 받을 수 있음. 즉 인자없는 기존 모디파이어를 이렇게 바꿔주어야 한다. .focusable(interactions: .activate)

 

또 한 가지 신경쓰이는 점은, 맥OS가 포커스를 표현할 때 사각형으로 그린다는 점. 좀 더 단정하게 이 캡슐 모양 백그라운드를 따라서 포커스가 그려지길 원하기 때문에 아래와 같이 코드를 바꿔준다. 

 

 

EmojiContainer { ratingOptions }
  .contentShape(.capsule) // 이걸로 포커스를 캡슐 모양으로 그림.
  .focusable(interactions: .activate)

 

이제 컨트롤에 포커스가 가도록 만들었다. 다음 단계는 키 눌림을 제어하는 것.

 

 

좌우 화살표 키를 사용해서 이모지 선택을 변경할 수 있게 하고 싶음. onMoveCommand() 모디파이어를 이용해서 플랙폼에 맞는 이동 커맨드에 반응하도록 할 것. 여기서는 화살표 키가 된다. 

 

watchOS에서도 똑같은 코드를 사용 가능하다. 

 

@Environment(\.layoutDirection) private var layoutDirection // LTR, RTL 체크 

#if os(macOS)
  .onMoveCommand { direction in
      selectRating(direction, layoutDirection: layoutDirection)
  }
  #endif
  #if os(watchOS)
  .digitalCrownRotation($digitalCrownRotation, from: 0, through: Double(Rating.allCases.count - 1), by: 1, sensitivity: .low)
  .onChange(of: digitalCrownRotation) { oldValue, newValue in
      if let rating = Rating(rawValue: Int(round(digitalCrownRotation))) {
          self.rating = rating
      }
  }
  #endif

 

- 포커스 가능한 컨트롤을 커스텀하기 전체 코드

struct RatingPicker: View {
    @Environment(\.layoutDirection) private var layoutDirection
    @Binding var rating: Rating?

    #if os(watchOS)
    @State private var digitalCrownRotation = 0.0
    #endif

    var body: some View {
        EmojiContainer { ratingOptions }
            .contentShape(.capsule)
            .focusable(interactions: .activate)
            #if os(macOS)
            .onMoveCommand { direction in
                selectRating(direction, layoutDirection: layoutDirection)
            }
            #endif
            #if os(watchOS)
            .digitalCrownRotation($digitalCrownRotation, from: 0, through: Double(Rating.allCases.count - 1), by: 1, sensitivity: .low)
            .onChange(of: digitalCrownRotation) { oldValue, newValue in
                if let rating = Rating(rawValue: Int(round(digitalCrownRotation))) {
                    self.rating = rating
                }
            }
            #endif
    }

    private var ratingOptions: some View {
        ForEach(Rating.allCases) { rating in
            EmojiView(rating: rating, isSelected: self.rating == rating) {
                self.rating = rating
            }
        }
    }

    #if os(macOS)
    private func selectRating(
        _ direction: MoveCommandDirection, layoutDirection: LayoutDirection
    ) {
        var direction = direction

        if layoutDirection == .rightToLeft {
            switch direction {
            case .left: direction = .right
            case .right: direction = .left
            default: break
            }
        }

        if let rating {
            switch direction {
            case .left:
                guard let previousRating = rating.previous else { return }
                self.rating = previousRating
            case .right:
                guard let nextRating = rating.next else { return }
                self.rating = nextRating
            default:
                break
            }
        }
    }
    #endif
}

private struct EmojiContainer<Content: View>: View {
    @Environment(\.isFocused) private var isFocused
    private var content: Content

    #if os(watchOS)
    private var strokeColor: Color {
        isFocused ? .green : .clear
    }
    #endif

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content()
    }

    var body: some View {
        HStack(spacing: 2) {
            content
        }
            .frame(height: 32)
            .font(.system(size: 24))
            .padding(.horizontal, 8)
            .padding(.vertical, 6)
            .background(.quaternary)
            .clipShape(.capsule)
            #if os(watchOS)
            .overlay(
                Capsule()
                    .strokeBorder(strokeColor, lineWidth: 1.5)
            )
            #endif
    }
}

private struct EmojiView: View {
    var rating: Rating
    var isSelected: Bool
    var action: () -> Void

    var body: some View {
        ZStack {
            Circle()
                .fill(isSelected ? Color.accentColor : Color.clear)
            Text(verbatim: rating.emoji)
                .onTapGesture { action() }
                .accessibilityLabel(rating.localizedName)
        }
    }
}

enum Rating: Int, CaseIterable, Identifiable {
    case meh
    case yummy
    case delicious

    var id: RawValue { rawValue }

    var emoji: String {
        switch self {
        case .meh:
            return "😕"
        case .yummy:
            return "🙂"
        case .delicious:
            return "🥰"
        }
    }

    var localizedName: LocalizedStringKey {
        switch self {
        case .meh:
            return "Meh"
        case .yummy:
            return "Yummy"
        case .delicious:
            return "Delicious"
        }
    }

    var previous: Rating? {
        let ratings = Rating.allCases
        let index = ratings.firstIndex(of: self)!

        guard index != ratings.startIndex else {
            return nil
        }

        let previousIndex = ratings.index(before: index)
        return ratings[previousIndex]
    }

    var next: Rating? {
        let ratings = Rating.allCases
        let index = ratings.firstIndex(of: self)!

        let nextIndex = ratings.index(after: index)
        guard nextIndex != ratings.endIndex else {
            return nil
        }

        return ratings[nextIndex]
    }
}

 


포커스가 가능한 그리드뷰

 

 

 

그리드로 짠 뷰가 있고 선택하는 기본적인 액션이 있음. Tab키를 눌러서 그리드가 포커스가 가게 하고 싶음. 그리고 포커스가 가면 화살표 키를 눌러서 선택된 그리드가 바뀌게 하고 싶음. 그 다음 Return 키를 누르면 선택된 레시피의 상세 화면으로 이동하게 하고 싶음.

 

이전에도 한 것처럼 그리드에 포커스가 갈 수 있게 focusable() 모디파이어를 LazyVGrid 뷰에 적용해줌. 인터랙션 인자를 지정해줄 필요는 없음. 기본적으로 그리드는 클릭할 때, 그리고 키보드에서 탭할 때, 키보드 내비게이션이 켜져있든 아니든, 포커스를 취하기 때문.

 

포커스가 가능하게 할 경우 기본적으로 시스템이 이렇게 포커스 주위에 테두리를 만들어주는데 선택된 콘텐트에 대해서 이런 이펙트가 불필요함. 선택된 레시피에 색상 보더가 추가되는 이 부분을 이미 만들어놨기 때문에.

 

.focusEffectDisabled()모디파이어를 이용해 자동적으로 생기는 포커스 링을 꺼줌.

 

private var strokeStyle: AnyShapeStyle {
    isSelected
        ? AnyShapeStyle(.selection)
        : AnyShapeStyle(.clear)
} // 뷰가 선택되었는지 여부 따라서 보더를 정해주는 코드

 

다음으로는 메인 메뉴 명령을 훅업해서 선택한 아이템이 메뉴아이템에서 이름이 뜨고 즐겨찾기로 추가할 수 있게 하기.

 

@State private var selection: Recipe.ID = Recipe.examples.first!.id

LazyVGrid(columns: columns) {
 // ...
} 
.focusable()
.focusEffectDisabled()
.focusedValue(\.selectedRecipe, $selection)
.onMoveCommand { direction in
    selectRecipe(direction, layoutDirection: layoutDirection)
}
.onKeyPress(.return) {
    navigateToRecipe(id: selection)
    return .handled
}

.focusedValueAPI를 이용해서 선택된 레시피를 바인딩해서 메뉴 커맨드가 업데이트 되도록 할 것.

 

이전처럼 .onMoveCommand 를 이용해서 방향키로 선택이 가능하게 함.

 

그 다음 Return 키를 누르면 상세페이지로 이동하는 등의 액션을 하고 싶음. .onKeyPress(.return) 모디파이어를 이용할 것. macOS Sonoma와 iOS 17에서 새로 나온 모디파이어로 이 모디파이어는 키나 문자를 취해서, 연결된 물리 키보드에서 실제로 해당 키가 눌리면 액션을 하도록 함.

 

키가 눌렸는지를 핸들링하지 않으면 Return 키를 눌러도 액션이 이루어지지 않고 뷰계층을 따라 올라가서 Return 이 디스패치 될 것.

 

.onKeyPress(characters: .alphanumerics, phases: .down) { keyPress in
    selectRecipe(matching: keyPress.characters)
}

 

부가적으로 타입 셀렉션(즉, 글자 타이핑을 통해 선택하는 것)을 하기 위해 .onKeyPress 모디파이어를 사용. 레시피의 첫 글자와 일치하는 키를 누르면 해당 레시피로 빠르게 스크롤되고, 그 레시피에 포커스가 가게 된다.

 

 

위의 경우 P키를 누르면, P로 시작하는 Pie Crust로 스크롤이 되고 포커스가 간 것을 살펴볼 수 있다.

 

 

- 그리드뷰 전체코드

struct ContentView: View {
    @State private var recipes = Recipe.examples
    @State private var selection: Recipe.ID = Recipe.examples.first!.id
    @Environment(\.layoutDirection) private var layoutDirection

    var body: some View {
        LazyVGrid(columns: columns) {
            ForEach(recipes) { recipe in
                RecipeTile(recipe: recipe, isSelected: recipe.id == selection)
                    .id(recipe.id)
                    #if os(macOS)
                    .onTapGesture { selection = recipe.id }
                    .simultaneousGesture(TapGesture(count: 2).onEnded {
                        navigateToRecipe(id: recipe.id)
                    })
                    #else
                    .onTapGesture { navigateToRecipe(id: recipe.id) }
                    #endif
            }
        }
        .focusable()
        .focusEffectDisabled()
        .focusedValue(\.selectedRecipe, $selection)
        .onMoveCommand { direction in
            selectRecipe(direction, layoutDirection: layoutDirection)
        }
        .onKeyPress(.return) {
            navigateToRecipe(id: selection)
            return .handled
        }
        .onKeyPress(characters: .alphanumerics, phases: .down) { keyPress in
            selectRecipe(matching: keyPress.characters)
        }
    }

    private var columns: [GridItem] {
        [ GridItem(.adaptive(minimum: RecipeTile.size), spacing: 0) ]
    }

    private func navigateToRecipe(id: Recipe.ID) {
        // ...
    }

    private func selectRecipe(
        _ direction: MoveCommandDirection, layoutDirection: LayoutDirection
    ) {
        // ...
    }

    private func selectRecipe(matching characters: String) -> KeyPress.Result {
        // ...
        return .handled
    }
}

struct RecipeTile: View {
    static let size = 240.0
    static let selectionStrokeWidth = 4.0

    var recipe: Recipe
    var isSelected: Bool

    private var strokeStyle: AnyShapeStyle {
        isSelected
            ? AnyShapeStyle(.selection)
            : AnyShapeStyle(.clear)
    }

    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 20)
                .fill(.background)
                .strokeBorder(
                    strokeStyle,
                    lineWidth: Self.selectionStrokeWidth)
                .frame(width: Self.size, height: Self.size)
            Text(recipe.name)
        }
    }
}

struct SelectedRecipeKey: FocusedValueKey {
    typealias Value = Binding<Recipe.ID>
}

extension FocusedValues {
    var selectedRecipe: Binding<Recipe.ID>? {
        get { self[SelectedRecipeKey.self] }
        set { self[SelectedRecipeKey.self] = newValue }
    }
}

struct RecipeCommands: Commands {
    @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe.ID?

    var body: some Commands {
        CommandMenu("Recipe") {
            Button("Add to Grocery List") {
                if let selectedRecipe {
                    addRecipe(selectedRecipe)
                }
            }
            .disabled(selectedRecipe == nil)
        }
    }

    private func addRecipe(_ recipe: Recipe.ID) { /* ... */ }
}

struct Recipe: Hashable, Identifiable {
    static let examples: [Recipe] = [
        Recipe(name: "Apple Pie"),
        Recipe(name: "Baklava"),
        Recipe(name: "Crème Brûlée")
    ]

    let id = UUID()
    var name = ""
    var imageName = ""
}

 


tvOS는 그리드의 각 셀은 모두 포커스가 가능하다. 리모콘으로 포커스가 다른 방향으로 가게 되면 해당 방향의 셀이 선택되고 다른 셀보다 위에 뜨게 된다. (이는 tvOS의 시스템 포커스가 갔을 때의 기본 비주얼) 시스템은 기본적으로 후버(또는 리프트) 효과를 버튼과 네비게이션 링크에 사용한다. 이 효과는 텍스트가 있는 뷰나, 텍스트와 이미지가 있는 뷰에 적절함.

 

tvOS 17에서는 포커스가능한 뷰에 하이라이트된 후버 효과를 적용할 수 있다. 시점이 변하고 반짝이는 효과가 있음.

 

 

- tvOS에서 포커스 가능한 그리드 뷰 코드

 

struct ContentView: View {
    var body: some View {
        HStack {
            VStack {
                List(["Dessert", "Pancake", "Salad", "Sandwich"], id: \.self) {
                    NavigationLink($0, destination: Color.gray)
                }
                Spacer()
            }
            .focusSection()

            ScrollView {
                LazyVGrid(columns: [GridItem(), GridItem()]) {
                    RoundedRectangle(cornerRadius: 5.0)
                        .focusable()
                }
            }
            .focusSection()
        }
    }
}

 


 

 

 

오늘도 읽어주셔서 감사합니다. 

 

궁금하거나 나누고 싶은 얘기가 있으시면 댓글로 알려주세요!

재밌게 읽으셨다면 공감과 구독은 큰 힘이 됩니다. 

 

항상 감사합니다.

 

 

이 글은 doy.oopy.io 에도 발행되어 있습니다. 

 

반응형

댓글