💻 Programming 개발/🍎 iOS 개발, Swift

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

킴디 kimdee 2023. 9. 9. 22:41
반응형

 

시작하며

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

 

이번에 SwiftUI팀에서 앱의 Focus(이하 포커스, 초점이라는 좋은 한국어가 있지만 API 이름 자체가 포커스이기 때문에) 경험을 만들어줄 수 있는 강력한 툴을 가지고 돌아왔다. 포커스 드리븐 경험이라는게 어떤건지, 커스텀 뷰의 포커스 인터랙션과 키보드 인풋에 대해 알아보고자 한다.

 

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

https://developer.apple.com/videos/play/wwdc2023/10162/

 

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

The SwiftUI team is back in the coding

developer.apple.com

 

Focus API가 무엇일까?

포커스는 키보드의 키를 눌렀을 때, 애플 TV 리모콘을 스와이프 했을 때, 와치에서 디지털 크라운을 켰을 때 등에 어떻게 반응할지를 결정하는 툴이다. 이러한 인풋 방법은 공통적으로 중요한 디테일을 가지고 있는데, 인풋 장치에서는 온스크린에서 어떤 컨트롤이 되는지에 대해 충분한 정보를 제공하지는 않는다는 점이다.

 

마우스나 트랙패드를 사용할 때 온 스크린 커서는 인터랙션할 타겟을 찾기 위해 화면 좌표와 연결된다. 포커스는 커서 없이도 직접 입력할 수 있는 추가적인 정보를 제공한다. 뷰에 포커스가 있으면 시스템이 입력에 응답하기 위한 시작지점으로 잡게 된다.

  

포커스가 보여지는 형태

포커스는 단순히 디테일의 구현 뿐 아니라 앱을 사용하는 사람들에게도 중요하기 때문에 포커스된 뷰는 특별하게 강조되서 표시가 된다.

macOS 에서는 포커스된 뷰에 보더가 생기고 키보드 인풋을 받을거라는 걸 보여고, watchOS는 초록색 보더가 그려지고 tvOS는 다른 컨트롤보다 띄워져 있는(=후버) 모양새로 표시된다.

 

포커스의 역할 

  • 사용자 입력(키보드, 리모콘 등) 이 어디로 갈지 알려줌
  • 사용자가 앱의 어떤 부분과 인터랙팅 하고 있는지 비주얼 레퍼런스 포인트를 제공함

 

포커스의 동작

포커스는 커서와 많이 유사하게 동작하게 됨. 마우스 커서로 화면의 지점을 계속 추적하는 것 대신에, 포커스는 UI의 어떤 파트가 포커스 인풋의 타겟이 되는지를 추적함.

 

포커스와 관련된 요소들 

 

Focusable Views

포커스 인풋에 응답할 때 시작지점으로 사용되는 뷰. 다른 상황과 다른 이유에 따라 다른 컨트롤이 포커스될 수 있다.

macOS와 ipadOS의 텍스트필드와 버튼을 비교해볼 때, 텍스트필드는 항상 포커스가 있음. 탭 하거나 혹은 Tab 키를 이용해 이전 컨트롤에서 옮겼든, 이런 종류의 컨트롤은 편집하기 위해 포커스가 제공됨. 왜냐하면 계속해서 포커스 인풋을 캡쳐해야 되기 때문에.

버튼의 경우는 좀 다른데, 버튼은 클릭과 탭을 다루기 때문에 macOS와 iPad에서는 버튼을 탭했다고 포커스가 가지 않는다. 포커스를 주기 위해서는 시스템 설정에서 키보드 세팅에서 키보드 네비게이션을 켠 후 Tab키를 통해 버튼에 포커스를 줄 수 있다.

그 다음 스페이스 바를 눌러서 버튼을 활성화 할 수 있다.

활성화할 때 버튼에 초점이 생기는데 사실 이런 종류의 컨트롤은 본인들 작업을 하기 위해 포커스가 필요하진 않다. 그렇지만 시스템이 허용할 경우, 클릭과 탭에 포커스 드리븐으로 할 수 있게 포커스를 취할 순 있음.

 

iOS 17, macOS Sonoma에서, 포커스 시스템에 커스텀 컨트롤이 동참할 수 있는 새 API를 제공함. focusable 뷰 모디파이어를 적용하면 컨트롤에서 제공하는 포커스 인터랙션의 종류를 지정함으로써 결과로 나오는 동작을 미세 조정할 수 있음.

 

계속해서 포커스를 업데이트하는 컨트롤의 경우 .focusable(interactions: .edit) 으로 설정하고, 직접적인 포인터 활성화를 대신하기 위해서 포커스를 사용하는 컨트롤은 .focusable(interactions.activate)으로 설정함. 

 

// Focusable views

struct RecipeGrid: View {
    var body: some View {
        LazyVGrid(columns: [GridItem(), GridItem()]) {
            ForEach(0..<4) { _ in Capsule() }
        }
        .focusable(interactions: .edit) // 📌
    }
}

struct RatingPicker: View {
    var body: some View {
        HStack { Capsule() ; Capsule() }
            .focusable(interactions: .activate) // 📌
    }
}

인자를 제공하지 않으면, 시스템은 모든 인터랙션에 포커스를 컨트롤하도록 함.

 

macOS Sonoma 이전에, focusable 모디파이어는 활성화된 맥락에서만 제공했다. 이미 macOS 코드에서 focuasable 모디파이어를 사용하고 있으면 새 동작이 유즈케이스에 맞는지 검증해야될 것.

 

interactions 인자를 추가해서 업데이트 해야될 수 있음.

 

포커스의 상태 

순간과 순간의 포커스 시스템 상태. FocusState 시스템은 어떤 뷰가 포커스를 가지고 있는지 계속 추적하고, 앱은 이 정보를 계속 가지고 있으면서 인풋을 어떻게 관리하고 뷰를 어떻게 스타일할지 결정함. 시스템 상태를 관측하기 위해서 특정 뷰에서의 포커스와 연결하는 바인딩을 만들어주어야 한다. 뷰에서 이 바인딩을 읽고 포커스가 바뀌면 알림을 받게 된다. 예를 들면 뷰가 포커스되면, 또는 포커스가 사라지는 알림.

// Focus state

struct GroceryListView: View {
    @FocusState private var isItemFocused
    @State private var itemName = ""

    var body: some View {
        TextField("Item Name", text: $itemName)
            .focused($isItemFocused)

        Button("Done") { isItemFocused = false }
            .disabled(!isItemFocused)
    }
}

포커스 상태 프로퍼티인 Boolean 값은 하나의 뷰가 포커스 되어 있는지만을 알려준다.

 

만약 더 복잡한 케이스의 경우, 커스텀 데이터 타입을 사용해야 함.

 

 

 

Focused Values

Focused Value API는 사용자 인터페이스의 리모트 부분의 연결에 데이터 의존성을 어떻게 구축하는지에 대한 문제를 해결해준다.

이 API를 사용해서 액티브 씬에서 일어나는 일들을 기반으로 앱의 커맨드를 업데이트 할 수 있다. Focused Value는 서로 다른 요소들의 데이터 플로우가 가능하게 한다.

 

커스텀을 하나 만들어서 메인 메뉴 콘텐트에 적용하도록 해볼 것.

 

Focused Values를 만들어서 사용하는 건 커스텀 Environment 키와 오브젝트를 만들어 사용하는 것과 비슷함.

 

FocusedValueKey 프로토콜을 사용해서 새 키를 만들고 확장해서 새로운 키를 가져오고 세팅할 수 있는 연산 프로퍼티를 선언한다.

 

// Focused values

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

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

struct RecipeView: View {
    @Binding var recipe: Recipe

    var body: some View {
        VStack {
            Text(recipe.title)
        }
        .focusedSceneValue(\.selectedRecipe, $recipe)
// 우리가 사용하는, 해당 씬의 뷰에서 가져오는 값은 값이나 바인딩, ObservableObject가 될 수 있다. 
    }
}

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

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

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

struct Recipe: Hashable, Identifiable {
    let id = UUID()
    var title = ""
    var isFavorite = false
}

뷰 모디파이어를 해당 데이터와 연결해서, 포커스가 뷰 계층에 속하게 한다.

Environment 값과 마찬가지로, 다이나믹 프로퍼티를 선언함으로써 Focused Value에 접근할 수 있다. 여기서는 Focused Value가 바인딩이므로 @FocusedBinding 프로퍼티 래퍼를 사용해서 여기에 커스텀 키 패스를 제공할 것.

 

@FocusedBinding 은 포커스된 뷰와 뷰의 조상을 살피고 키와 연관된 바인딩이 있는지 확인한다. 프로퍼티 래퍼는 자동적으로 바인딩을 풀어서 바우딩 값을 바로 사용할 수 있게 해준다.

 

이제 이 새 프로퍼티를 view의 바디에서 사용하는 것. 시간이 지나면서 포커스가 다른 컨트롤과 혹은 활성화된 다른 윈도우로 이동하게 되는데, 시스템은 새로운 컨텍스트에서 찾은 값들을 반영하여 뷰를 업데이트 하게 될 것.

 

Focused Sections 

포커스 섹션을 이용하면, 사용자 인터랙션(애플 TV 리모콘을 스와이프하거나, 키보드에서 Tab키를 눌렀을 때 등) 포커스 이동방식에 영향을 줄 수 있음.

 

기본적으로 포커스는 화면의 leading 가장자리에서 가장 가까운 최상단의 컨트롤부터 시작한다.

 

여기서 Tab을 누르면 포커스가 현재 로케일의 레이아웃 순서에 따라서 다음 컨트롤로 이동한다.

 

Leading
: 한국어, 영어처럼 Left-to-right로 읽는 곳에서는 왼쪽이지만 아랍어같이 Right-to-left 인 나라의 경우 오른쪽
Locale
: 장소. 좀 더는 해당 지역과 문화권을 의미함. 위처럼 RTL인 아랍어권 국가의 경우, 다음으로 이동하게 되면 오른쪽에서 → 왼쪽으로 이동하게 될 것.

  

스크린의 마지막 컨트롤에 도달하면, Tab을 누를때 시퀀스의 맨 처음으로 다시 시작하게 된다.

애플 TV에서 포커스 이동은 방향성이 있다. 위, 아래, 좌, 우로 스와이프해서 컨트롤을 이동할 수 있다.

좌우로 디저트간을 이동할 순 있지만, 장보기 리스트에 크렘브륄레를 넣고 싶은데 스와이프 다운할 순 없음. 왜냐하면 이 버튼이 바로 크렘브륄레 아래에 위치하지 않기 때문에.

 

포커스 타겟을 정렬하기 위해서, 아래 버튼의 컨테이너를 포커스 섹션으로 마크할 것. 포커스 섹션이 이동 제스쳐의 타겟이 되지만 포커스가 되는게 아니라 가장 가까운 포커스될 수 있는 콘텐트를 가리키는 가이드가 된다.

 

포커스 섹션을 효과적으로 사용하려면 포커스 섹션이 콘텐트들보다 더 많은 공간을 차지하고 있어야 한다.

지금 같은 경우 버튼 전후로 Spacer()를 추가해서 스택이 스크린 너비에 맞게 커지도록 할 것.

더 큰 포커스 타겟이 되었으므로, 어느때고 스와이프 다운해서 아래 버튼에 접근할 수 있게 된다.

 

// Focus sections

struct ContentView: View {
    @State private var favorites = Recipe.examples
    @State private var selection = Recipe.examples.first!

    var body: some View {
        VStack {
            HStack {
                ForEach(favorites) { recipe in
                    Button(recipe.name) { selection = recipe }
                }
            }

            Image(selection.imageName)

            HStack {
                Spacer()
                Button("Add to Grocery List") { addIngredients(selection) }
                Spacer()
            }
            .focusSection()
        }
    }

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

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 = ""
}

 

Focus를 컨트롤하기

이 부분은 아래 2편에서 마저 다루었습니다. 

 

 

https://kimdee.tistory.com/entry/SwiftUI-Focus-에-관하여-2-WWDC-2023-영상-정리

 

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

이 글은 아래 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와

kimdee.tistory.com

 

감사합니다.


 

 

 

 

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

 

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

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

 

항상 감사합니다.

 

 

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

 

반응형