๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๐Ÿ’ป 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 ์—๋„ ๋ฐœํ–‰๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. 

 

๋ฐ˜์‘ํ˜•

'๐Ÿ’ป Programming ๊ฐœ๋ฐœ > ๐ŸŽ iOS ๊ฐœ๋ฐœ, Swift' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

[iOS] iMessage ์Šคํ‹ฐ์ปคํŒฉ ๋งŒ๋“ค๊ธฐ (+ ์†์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋Š” ํ”ผ๊ทธ๋งˆ ํ…œํ”Œ๋ฆฟ ๋ฌด๋ฃŒ์ œ๊ณต!)  (0) 2024.03.31
[Swift][์ฝ”๋”ฉํ…Œ์ŠคํŠธ] ๋ฐฑ์ค€ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ํ’€ ๋•Œ ํ•„์š”ํ•œ ์ฝ”๋“œ ์Šค๋‹ˆํŽซ ๋ชจ์Œ  (17) 2024.02.12
[SwiftUI] Focus ์— ๊ด€ํ•˜์—ฌ 1 - WWDC 2023 ์˜์ƒ ์ •๋ฆฌ  (0) 2023.09.09
2ํŽธ - iOS ์•ฑ์„ 2๊ฐœ ์ถœ์‹œํ•˜๊ณ  ๋‚˜์„œ ๋Œ์•„๋ณธ 10๊ฐœ์›”๊ฐ„์˜ iOS ๊ฐœ๋ฐœ ๊ณต๋ถ€ ๋ฐฉ๋ฒ•(2022) - ์ƒˆ์‹น ๋ถ€ํŠธ์บ ํ”„, ๊ฐœ์ธ์•ฑ ์ถœ์‹œ, ์—ฐํ•ฉ๋™์•„๋ฆฌ UMC, ์‚ฌ์ด๋“œํ”„๋กœ์ ํŠธ, ์ฝ”๋”ฉํ…Œ์ŠคํŠธ, ๋ถ€ํŠธ์บ ํ”„ ์†Œ๊ฐœ  (1) 2023.07.31
1ํŽธ - iOS ์•ฑ์„ 2๊ฐœ ์ถœ์‹œํ•˜๊ณ  ๋‚˜์„œ ๋Œ์•„๋ณธ 10๊ฐœ์›”๊ฐ„์˜ iOS ๊ฐœ๋ฐœ ๊ณต๋ถ€ ๋ฐฉ๋ฒ•(2022) - ๋…ํ•™, ๊ฐœ์ธํ”„๋กœ์ ํŠธ, ์ฑ… ์ถ”์ฒœ  (8) 2023.07.31

๋Œ“๊ธ€