์ด ๊ธ์ ์๋ WWDC2023 ์์์ ์ฐธ๊ณ ํ์ฌ ์ ๋ฆฌํ์์ต๋๋ค.
https://developer.apple.com/wwdc23/10162
ํฌ์ปค์ค API์ ๊ด๋ จ ์์๋ค์ ์๋ 1ํธ์์ ์ ๋ฆฌ๋์ด ์์ต๋๋ค.
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 ์๋ ๋ฐํ๋์ด ์์ต๋๋ค.
๋๊ธ