Ver3.0 ๐ iOS ์ ๊ทผ์ฑ๊ณผ VoiceOver๋ฅผ ๊ณ ๋ คํ ํฌ์ฉ์ ์ฑ ์ค๊ณ: ๋ชจ๋๋ฅผ ์ํ ๋์งํธ ์ธ์ ๋ง๋ค๊ธฐ

๐ iOS ์ ๊ทผ์ฑ๊ณผ VoiceOver๋ฅผ ๊ณ ๋ คํ ํฌ์ฉ์ ์ฑ ์ค๊ณ: ๋ชจ๋๋ฅผ ์ํ ๋์งํธ ์ธ์ ๋ง๋ค๊ธฐ
๋ชจ๋ ์ฌ์ฉ์๊ฐ ํธ์ํ๊ฒ ์ฌ์ฉํ ์ ์๋ ์ฑ, ๊ทธ๊ฒ์ด ์ง์ ํ ์ข์ ์ฑ์ ๋๋ค โจ
๐ฏ ์๋ , ์น๊ตฌ! ์ค๋์ ์ ๋ง ์ค์ํ ์ด์ผ๊ธฐ๋ฅผ ํด๋ณด๋ ค๊ณ ํด. ํน์ ๋์ ๊ฐ๊ณ ์ค๋งํธํฐ์ ์ฌ์ฉํด๋ณธ ์ ์์ด? ์๋ง ๋๋ถ๋ถ์ ์์ ๊ฑฐ์ผ. ํ์ง๋ง ์ ์ธ๊ณ ์์ต ๋ช ์ ์ฌ๋๋ค์ ๋งค์ผ ๊ทธ๋ ๊ฒ ๋์งํธ ์ธ์๊ณผ ์ํตํ๊ณ ์์ด. ๋ฐ๋ก ์๊ฐ์ฅ์ ์ธ๋ค์ด์ง.
iOS ๊ฐ๋ฐ์๋ก์ ์ฐ๋ฆฌ๊ฐ ๋ง๋๋ ์ฑ์ ๋จ์ํ '์๋ํ๋' ๊ฒ์ ๋์ด์ '๋ชจ๋๊ฐ ์ฌ์ฉํ ์ ์๋' ๊ฒ์ด์ด์ผ ํด. ์ด๊ฒ ๋ฐ๋ก ์ ๊ทผ์ฑ(Accessibility)์ด์ผ. ๊ทธ๋ฆฌ๊ณ Apple์ VoiceOver๋ ์ด ์ ๊ทผ์ฑ์ ์คํํ๋ ํต์ฌ ๋๊ตฌ์ง. ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ๋ค์ํ ์ฌ์ฉ์๋ค์ด ์๋น์ค๋ฅผ ์ด์ฉํ์์? ๊ทธ๋ค ๋ชจ๋๊ฐ ํธ์ํ๊ฒ ์ฌ์ฉํ ์ ์์ด์ผ ์ง์ ํ ์ฑ๊ณต์ด๋ผ๊ณ ํ ์ ์์ด! ๐
๐จ ์ ๊ทผ์ฑ์ด๋ ๋ฌด์์ผ๊น? ์ ์ค์ํ ๊น?
์ ๊ทผ์ฑ(Accessibility)์ ๋จ์ํ '์ฅ์ ์ธ์ ์ํ ๊ธฐ๋ฅ'์ด ์๋์ผ. ์ด๊ฑด ๋ชจ๋ ์ฌ์ฉ์๊ฐ ๋๋ฑํ๊ฒ ๋์งํธ ์ฝํ ์ธ ์ ์ ๊ทผํ๊ณ ์ฌ์ฉํ ์ ์๋๋ก ํ๋ ์ค๊ณ ์ฒ ํ์ด์ผ. ์๊ฐํด๋ด, ๋ฐ์ ํ๋น ์๋์์ ํ๋ฉด์ด ์ ์ ๋ณด์ผ ๋, ์๋๋ฌ์ด ์งํ์ฒ ์์ ์๋ฆฌ๋ฅผ ๋ค์ ์ ์์ ๋, ํ ์์ผ๋ก๋ง ํฐ์ ์จ์ผ ํ ๋... ์ด ๋ชจ๋ ์ํฉ์์ ์ ๊ทผ์ฑ ๊ธฐ๋ฅ์ด ๋์์ด ๋ผ.
๐ ๋๋ผ์ด ํต๊ณ๋ค:
โข ์ ์ธ๊ณ ์ธ๊ตฌ์ ์ฝ 15%(10์ต ๋ช
์ด์)๊ฐ ์ด๋ค ํํ๋ก๋ ์ฅ์ ๋ฅผ ๊ฐ์ง๊ณ ์์ด
โข WHO์ ๋ฐ๋ฅด๋ฉด ์ ์ธ๊ณ ์๊ฐ์ฅ์ ์ธ์ ์ฝ 2์ต 8,500๋ง ๋ช
โข 65์ธ ์ด์ ์ธ๊ตฌ ์ค ์ฝ 1/3์ด ์๋ ฅ ์ ํ๋ฅผ ๊ฒฝํํด
โข ์ ๊ทผ์ฑ์ด ์ข์ ์น์ฌ์ดํธ๋ ํ๊ท 50% ๋ ๋ง์ ํธ๋ํฝ์ ๋ฐ์
Apple์ ์ ๊ทผ์ฑ์ ์ ํ ์ค๊ณ์ ํต์ฌ์ผ๋ก ๋๊ณ ์์ด. Tim Cook CEO๋ "๊ธฐ์ ์ ๋ชจ๋ ์ฌ๋์ ์ํ ๊ฒ"์ด๋ผ๊ณ ๋งํ์ง. ๊ทธ๋์ iOS์๋ VoiceOver, Switch Control, AssistiveTouch ๋ฑ ๋ค์ํ ์ ๊ทผ์ฑ ๊ธฐ๋ฅ์ด ๊ธฐ๋ณธ ํ์ฌ๋์ด ์์ด. ๐
| ์์น | ์๋ฏธ | ์์ |
|---|---|---|
| ์ธ์ง ๊ฐ๋ฅ์ฑ (Perceivable) |
์ ๋ณด์ UI ์์๊ฐ ์ฌ์ฉ์๊ฐ ์ธ์งํ ์ ์๋ ๋ฐฉ์์ผ๋ก ์ ๊ณต๋์ด์ผ ํจ | ์ด๋ฏธ์ง์ ๋์ฒด ํ ์คํธ ์ ๊ณต, ์ถฉ๋ถํ ์์ ๋๋น |
| ์๋ ๊ฐ๋ฅ์ฑ (Operable) |
UI ์์์ ๋ด๋น๊ฒ์ด์ ์ด ์๋ ๊ฐ๋ฅํด์ผ ํจ | ํค๋ณด๋๋ง์ผ๋ก ๋ชจ๋ ๊ธฐ๋ฅ ์ฌ์ฉ ๊ฐ๋ฅ, ์ถฉ๋ถํ ํฐ์น ์์ญ |
| ์ดํด ๊ฐ๋ฅ์ฑ (Understandable) |
์ ๋ณด์ UI ์๋์ด ์ดํด ๊ฐ๋ฅํด์ผ ํจ | ๋ช ํํ ๋ ์ด๋ธ, ์ผ๊ด๋ ๋ด๋น๊ฒ์ด์ , ์ค๋ฅ ๋ฉ์์ง ์ ๊ณต |
| ๊ฒฌ๊ณ ์ฑ (Robust) |
๋ค์ํ ๋ณด์กฐ ๊ธฐ์ ๊ณผ ํธํ๋์ด์ผ ํจ | ํ์ค UI ์ปดํฌ๋ํธ ์ฌ์ฉ, ์ ์ ํ ์ ๊ทผ์ฑ ์์ฑ ์ค์ |
๐ค VoiceOver: iOS์ ์คํฌ๋ฆฐ ๋ฆฌ๋
VoiceOver๋ iOS์ ๋ด์ฅ๋ ์คํฌ๋ฆฐ ๋ฆฌ๋(ํ๋ฉด ์ฝ๊ธฐ ํ๋ก๊ทธ๋จ)์ผ. ํ๋ฉด์ ๋ด์ฉ์ ์์ฑ์ผ๋ก ์ฝ์ด์ฃผ๊ณ , ์ ์ค์ฒ๋ฅผ ํตํด ์ฑ์ ํ์ํ๊ณ ์กฐ์ํ ์ ์๊ฒ ํด์ค. ์๊ฐ์ฅ์ ์ธ๋ค์๊ฒ๋ ๋์งํธ ์ธ์์ผ๋ก ๊ฐ๋ ๋ฌธ์ด๋ ๋ง์ฐฌ๊ฐ์ง์ง! ๐ช
๐ก VoiceOver ์ฒดํํด๋ณด๊ธฐ
์ค์ > ์์ฌ์ด ์ฌ์ฉ > VoiceOver๋ฅผ ์ผ๊ณ ์ง์ ์ฌ์ฉํด๋ด. ๋์ ๊ฐ๊ณ ์ฑ์ ์ฌ์ฉํด๋ณด๋ฉด ๊ฐ๋ฐ์๋ก์ ์์ ํ ์๋ก์ด ๊ด์ ์ ์ป์ ์ ์์ด. ์ด๊ฑด ์ ๋ง ๊ฐ๋ ฅ ์ถ์ฒ์ด์ผ!
๊ธฐ๋ณธ ์ ์ค์ฒ:
โข ํ ์๊ฐ๋ฝ์ผ๋ก ํญ: ํญ๋ชฉ ์ ํ ๋ฐ ์ฝ๊ธฐ
โข ๋ ์๊ฐ๋ฝ์ผ๋ก ํญ: ์์
์ํ (์ผ๋ฐ์ ์ธ ํญ๊ณผ ๋์ผ)
โข ์ธ ์๊ฐ๋ฝ์ผ๋ก ์ค์์ดํ: ํ์ด์ง ์คํฌ๋กค
โข ๋ ์๊ฐ๋ฝ์ผ๋ก Z ๊ทธ๋ฆฌ๊ธฐ: ๋ค๋ก ๊ฐ๊ธฐ
VoiceOver๋ ํ๋ฉด์ ์์๋ค์ ์์ฐจ์ ์ผ๋ก ํ์ํด. ๊ฐ ์์์ ๋ํด ๋ค์ ์ ๋ณด๋ค์ ์ฝ์ด์ค:
1. Label (๋ ์ด๋ธ): "๋ก๊ทธ์ธ ๋ฒํผ"
2. Trait (ํน์ฑ): "๋ฒํผ"
3. Value (๊ฐ): ํ์ฌ ์ํ๋ ๊ฐ
4. Hint (ํํธ): "๋ ๋ฒ ํญํ์ฌ ๋ก๊ทธ์ธํ์ธ์"
์๋ฅผ ๋ค์ด, ์๋ ์ฌ๋ผ์ด๋๋ฅผ VoiceOver๋ก ํ์ํ๋ฉด ์ด๋ ๊ฒ ๋ค๋ ค:
"์๋, 50%, ์กฐ์ ๊ฐ๋ฅ, ์๋ ์๋๋ก ์ธ์ด ๋๊ฒจ ๊ฐ์ ์กฐ์ ํ์ธ์" ๐
โ ๏ธ ํํ ์ค์
๋ง์ ๊ฐ๋ฐ์๋ค์ด VoiceOver๋ฅผ "๋์ค์ ์ถ๊ฐํ ๊ธฐ๋ฅ"์ผ๋ก ์๊ฐํด. ํ์ง๋ง ์ ๊ทผ์ฑ์ ์ฒ์๋ถํฐ ์ค๊ณ์ ํฌํจ๋์ด์ผ ํด. ๋์ค์ ์ถ๊ฐํ๋ ค๋ฉด ํจ์ฌ ๋ ๋ง์ ์๊ฐ๊ณผ ๋ ธ๋ ฅ์ด ๋ค์ด๊ฐ๊ฑฐ๋ !
๐ ๏ธ Swift๋ก ์ ๊ทผ์ฑ ๊ตฌํํ๊ธฐ: ์ค์ ๊ฐ์ด๋
์, ์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ์ฝ๋๋ฅผ ์์ฑํด๋ณผ๊น? Swift์์ ์ ๊ทผ์ฑ์ ๊ตฌํํ๋ ๊ฑด ์๊ฐ๋ณด๋ค ๊ฐ๋จํด. UIKit๊ณผ SwiftUI ๋ชจ๋ ๊ฐ๋ ฅํ ์ ๊ทผ์ฑ API๋ฅผ ์ ๊ณตํ๊ฑฐ๋ ! ๐
UIKit์ ๋ชจ๋ UI ์์๋ ๊ธฐ๋ณธ์ ์ผ๋ก UIAccessibility ํ๋กํ ์ฝ์ ์ฑํํ๊ณ ์์ด. ์ด ํ๋กํ ์ฝ์ด ์ ๊ณตํ๋ ์์ฑ๋ค์ ์ค์ ํ๋ฉด ๋ผ.
// ๊ธฐ๋ณธ์ ์ธ ์ ๊ทผ์ฑ ์ค์
let loginButton = UIButton()
loginButton.setTitle("๋ก๊ทธ์ธ", for: .normal)
// ์ ๊ทผ์ฑ ํ์ฑํ
loginButton.isAccessibilityElement = true
// ๋ ์ด๋ธ ์ค์ (VoiceOver๊ฐ ์ฝ์ ํ
์คํธ)
loginButton.accessibilityLabel = "๋ก๊ทธ์ธ"
// ํน์ฑ ์ค์ (์์์ ํ์
)
loginButton.accessibilityTraits = .button
// ํํธ ์ค์ (์ฌ์ฉ ๋ฐฉ๋ฒ ์๋ด)
loginButton.accessibilityHint = "๋ ๋ฒ ํญํ์ฌ ๊ณ์ ์ ๋ก๊ทธ์ธํ์ธ์"
// ๊ฐ ์ค์ (๋์ ์ผ๋ก ๋ณํ๋ ์ ๋ณด)
// ์: ํ ๊ธ ๋ฒํผ์ ๊ฒฝ์ฐ
loginButton.accessibilityValue = "ํ์ฑํ๋จ"
๐ก Pro Tip: ๋ ์ด๋ธ vs ํํธ
โข Label: ์์๊ฐ ๋ฌด์์ธ์ง ์ค๋ช
(ํ์)
โข Hint: ์์๋ฅผ ์ด๋ป๊ฒ ์ฌ์ฉํ๋์ง ์ค๋ช
(์ ํ์ )
โข Value: ์์์ ํ์ฌ ์ํ๋ ๊ฐ (๋์ ์ ๋ณด)
ํํธ๋ VoiceOver ์ค์ ์์ ๋ ์ ์์ผ๋, ์ค์ํ ์ ๋ณด๋ ๋ ์ด๋ธ์ ๋ฃ์ด์ผ ํด!
์ปค์คํ ๋ทฐ๋ฅผ ๋ง๋ค ๋๋ ์ ๊ทผ์ฑ์ ๋ ์ ๊ฒฝ ์จ์ผ ํด. ์ฌ๋ฌ ํ์ ๋ทฐ๋ฅผ ํ๋์ ์ ๊ทผ์ฑ ์์๋ก ๊ทธ๋ฃนํํ๊ฑฐ๋, ๋ณต์กํ ์ธํฐ๋์ ์ ์ค๋ช ํด์ผ ํ ์๋ ์๊ฑฐ๋ .
class ProfileCardView: UIView {
let profileImageView = UIImageView()
let nameLabel = UILabel()
let bioLabel = UILabel()
let followButton = UIButton()
override init(frame: CGRect) {
super.init(frame: frame)
setupAccessibility()
}
func setupAccessibility() {
// ์ ์ฒด ์นด๋๋ฅผ ํ๋์ ์ ๊ทผ์ฑ ์์๋ก ๋ง๋ค๊ธฐ
isAccessibilityElement = true
accessibilityTraits = .button
// ํ์ ๋ทฐ๋ค์ ๊ฐ๋ณ์ ์ผ๋ก ์ ๊ทผ ๋ถ๊ฐ๋ฅํ๊ฒ
profileImageView.isAccessibilityElement = false
nameLabel.isAccessibilityElement = false
bioLabel.isAccessibilityElement = false
followButton.isAccessibilityElement = false
}
func updateAccessibility(name: String, bio: String, isFollowing: Bool) {
// ๋ชจ๋ ์ ๋ณด๋ฅผ ํ๋์ ๋ ์ด๋ธ๋ก ๊ฒฐํฉ
accessibilityLabel = "\(name)์ ํ๋กํ"
// ์์ธ ์ ๋ณด๋ฅผ ๊ฐ์ผ๋ก ์ ๊ณต
accessibilityValue = bio
// ํ์ฌ ํ๋ก์ฐ ์ํ์ ๋ฐ๋ฅธ ํํธ
if isFollowing {
accessibilityHint = "๋ ๋ฒ ํญํ์ฌ ํ๋ก์ฐ ์ทจ์"
} else {
accessibilityHint = "๋ ๋ฒ ํญํ์ฌ ํ๋ก์ฐ"
}
}
}
์ด๋ ๊ฒ ํ๋ฉด VoiceOver ์ฌ์ฉ์๋ ์นด๋ ์ ์ฒด๋ฅผ ํ๋์ ์์๋ก ์ธ์ํ๊ณ , ๋ชจ๋ ์ ๋ณด๋ฅผ ํ ๋ฒ์ ๋ค์ ์ ์์ด. ํจ์ฌ ํจ์จ์ ์ด์ง! ๐ฏ
SwiftUI๋ ์ ๊ทผ์ฑ์ ๋ ์ ์ธ์ ์ด๊ณ ์ง๊ด์ ์ผ๋ก ๋ค๋ฃฐ ์ ์์ด. modifier๋ฅผ ์ฒด์ด๋ํ๋ ๋ฐฉ์์ด๋ผ ์ฝ๋๋ ๊น๋ํ๊ณ !
import SwiftUI
struct LoginView: View {
@State private var username = ""
@State private var password = ""
@State private var isLoading = false
var body: some View {
VStack(spacing: 20) {
// ํ
์คํธ ํ๋ ์ ๊ทผ์ฑ
TextField("์ฌ์ฉ์ ์ด๋ฆ", text: $username)
.accessibilityLabel("์ฌ์ฉ์ ์ด๋ฆ ์
๋ ฅ")
.accessibilityHint("๊ณ์ ์ ์ฌ์ฉ์ ์ด๋ฆ์ ์
๋ ฅํ์ธ์")
SecureField("๋น๋ฐ๋ฒํธ", text: $password)
.accessibilityLabel("๋น๋ฐ๋ฒํธ ์
๋ ฅ")
.accessibilityHint("๊ณ์ ์ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์")
// ๋ฒํผ ์ ๊ทผ์ฑ
Button(action: login) {
if isLoading {
ProgressView()
.accessibilityLabel("๋ก๊ทธ์ธ ์ค")
} else {
Text("๋ก๊ทธ์ธ")
}
}
.accessibilityLabel("๋ก๊ทธ์ธ ๋ฒํผ")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ ๋ก๊ทธ์ธ")
.disabled(username.isEmpty || password.isEmpty)
// ์ด๋ฏธ์ง ์ ๊ทผ์ฑ
Image("logo")
.accessibilityLabel("์ฌ๋ฅ๋ท ๋ก๊ณ ")
.accessibilityHidden(true) // ์ฅ์์ฉ ์ด๋ฏธ์ง๋ ์จ๊ธฐ๊ธฐ
}
}
func login() {
isLoading = true
// ๋ก๊ทธ์ธ ๋ก์ง...
}
}
๐ก SwiftUI ์ ๊ทผ์ฑ Modifier ๋ชจ์
โข .accessibilityLabel(): ์์ ์ค๋ช
โข .accessibilityValue(): ํ์ฌ ๊ฐ
โข .accessibilityHint(): ์ฌ์ฉ ๋ฐฉ๋ฒ
โข .accessibilityHidden(): VoiceOver์์ ์จ๊ธฐ๊ธฐ
โข .accessibilityElement(children:): ์์ ์์ ๊ทธ๋ฃนํ
โข .accessibilityAddTraits(): ํน์ฑ ์ถ๊ฐ
โข .accessibilityRemoveTraits(): ํน์ฑ ์ ๊ฑฐ
โข .accessibilityAction(): ์ปค์คํ
์ก์
์ถ๊ฐ
Traits๋ VoiceOver์๊ฒ ์์์ ํ์ ๊ณผ ์ํ๋ฅผ ์๋ ค์ค. ์ฌ๋ฐ๋ฅธ trait๋ฅผ ์ค์ ํ๋ฉด ์ฌ์ฉ์๊ฐ ์์๋ฅผ ๋ ์ ์ดํดํ ์ ์์ด.
| Trait | ์ฉ๋ | VoiceOver ์ฝ๊ธฐ |
|---|---|---|
.button |
๋ฒํผ ์์ | "๋ฒํผ"์ด๋ผ๊ณ ์ฝ์ด์ค |
.link |
๋งํฌ ์์ | "๋งํฌ"๋ผ๊ณ ์ฝ์ด์ค |
.header |
ํค๋/์ ๋ชฉ | ๋ ํฐ ๋ชฉ์๋ฆฌ๋ก ์ฝ์ด์ค |
.selected |
์ ํ๋ ์ํ | "์ ํ๋จ"์ด๋ผ๊ณ ์ถ๊ฐ |
.image |
์ด๋ฏธ์ง ์์ | "์ด๋ฏธ์ง"๋ผ๊ณ ์ฝ์ด์ค |
.adjustable |
์กฐ์ ๊ฐ๋ฅํ ์์ (์ฌ๋ผ์ด๋ ๋ฑ) | ์/์๋ ์ค์์ดํ๋ก ์กฐ์ ๊ฐ๋ฅ |
.updatesFrequently |
์์ฃผ ์ ๋ฐ์ดํธ๋๋ ์์ | ๋ณ๊ฒฝ์ฌํญ์ ์๋์ผ๋ก ์๋ฆผ |
// Traits ์กฐํฉ ์์
let playButton = UIButton()
playButton.accessibilityLabel = "์ฌ์"
playButton.accessibilityTraits = [.button, .startsMediaSession]
// SwiftUI์์
Button("์ฌ์") { }
.accessibilityAddTraits(.startsMediaSession)
// ์ฌ๋ฌ trait ์กฐํฉ
Text("์ค์ ๊ณต์ง")
.accessibilityAddTraits([.isHeader, .updatesFrequently])
๐จ ์๊ฐ์ ์ ๊ทผ์ฑ: ์์๊ณผ ๋๋น
VoiceOver๋ง์ด ์ ๊ทผ์ฑ์ ์ ๋ถ๊ฐ ์๋์ผ. ์๊ฐ์ ์ ๊ทผ์ฑ๋ ์์ฒญ ์ค์ํด! ์ ์๋ ฅ ์ฌ์ฉ์, ์๋งน ์ฌ์ฉ์๋ค๋ ๊ณ ๋ คํด์ผ ํ๊ฑฐ๋ . ๐
WCAG ๊ฐ์ด๋๋ผ์ธ์ ๋ฐ๋ฅด๋ฉด:
์ผ๋ฐ ํ
์คํธ (18pt ๋ฏธ๋ง)
โข AA ๋ฑ๊ธ: ์ต์ 4.5:1
โข AAA ๋ฑ๊ธ: ์ต์ 7:1
ํฐ ํ
์คํธ (18pt ์ด์ ๋๋ 14pt Bold)
โข AA ๋ฑ๊ธ: ์ต์ 3:1
โข AAA ๋ฑ๊ธ: ์ต์ 4.5:1
UI ์ปดํฌ๋ํธ ๋ฐ ๊ทธ๋ํฝ
โข ์ต์ 3:1
// Swift์์ ์์ ๋๋น ํ์ธํ๊ธฐ
extension UIColor {
// ์๋ ํ๋ ๊ณ์ฐ
var luminance: CGFloat {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: nil)
func adjust(component: CGFloat) -> CGFloat {
if component <= 0.03928 {
return component / 12.92
}
return pow((component + 0.055) / 1.055, 2.4)
}
let r = adjust(component: red)
let g = adjust(component: green)
let b = adjust(component: blue)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
// ๋๋น ๋น์จ ๊ณ์ฐ
func contrastRatio(with color: UIColor) -> CGFloat {
let l1 = self.luminance
let l2 = color.luminance
let lighter = max(l1, l2)
let darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
// WCAG AA ๊ธฐ์ค ํต๊ณผ ์ฌ๋ถ
func meetsWCAGAA(with backgroundColor: UIColor,
fontSize: CGFloat) -> Bool {
let ratio = contrastRatio(with: backgroundColor)
let threshold: CGFloat = fontSize >= 18 ? 3.0 : 4.5
return ratio >= threshold
}
}
// ์ฌ์ฉ ์์
let textColor = UIColor.black
let bgColor = UIColor.white
let ratio = textColor.contrastRatio(with: bgColor)
print("๋๋น ๋น์จ: \(ratio):1") // 21:1
if textColor.meetsWCAGAA(with: bgColor, fontSize: 16) {
print("โ
WCAG AA ๊ธฐ์ค ํต๊ณผ!")
}
๐ก ์์ ๋๋น ์ฒดํฌ ๋๊ตฌ
โข Xcode Accessibility Inspector: ๋ด์ฅ ๋๊ตฌ๋ก ์ค์๊ฐ ํ์ธ
โข Contrast Checker: WebAIM์ ์จ๋ผ์ธ ๋๊ตฌ
โข Stark: Figma/Sketch ํ๋ฌ๊ทธ์ธ
โข Color Oracle: ์๋งน ์๋ฎฌ๋ ์ดํฐ
์๋งน ์ฌ์ฉ์๋ฅผ ์ํด ์์ ์ธ์ ๋ค๋ฅธ ์๊ฐ์ ๋จ์๋ ํจ๊ป ์ ๊ณตํด์ผ ํด.
โ ๋์ ์์:
๋นจ๊ฐ์ ํ
์คํธ๋ก๋ง "์ค๋ฅ" ํ์
โ
์ข์ ์์:
๋นจ๊ฐ์ + โ ๏ธ ์์ด์ฝ + "์ค๋ฅ:" ํ
์คํธ ์กฐํฉ
// ์ ๊ทผ์ฑ ์ข์ ์๋ฌ ํ์
struct ErrorView: View {
let message: String
var body: some View {
HStack(spacing: 12) {
// ์์ด์ฝ์ผ๋ก ์๊ฐ์ ๋จ์ ์ ๊ณต
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.accessibilityHidden(true) // ์ค๋ณต ์ ๋ณด ๋ฐฉ์ง
VStack(alignment: .leading, spacing: 4) {
// ๋ช
ํํ ํ
์คํธ ๋ ์ด๋ธ
Text("์ค๋ฅ")
.font(.headline)
.foregroundColor(.red)
Text(message)
.font(.body)
.foregroundColor(.primary)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.red.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.red, lineWidth: 2) // ํ
๋๋ฆฌ๋ก ์ถ๊ฐ ๊ฐ์กฐ
)
)
.accessibilityElement(children: .combine)
.accessibilityLabel("์ค๋ฅ: \(message)")
}
}
์ฌ์ฉ์๊ฐ ์ค์ ํ ํ ์คํธ ํฌ๊ธฐ๋ฅผ ์กด์คํด์ผ ํด. iOS์ Dynamic Type์ ์ง์ํ๋ฉด ์ ์๋ ฅ ์ฌ์ฉ์๋ค์ด ํธํ๊ฒ ์ฝ์ ์ ์์ด.
// UIKit์์ Dynamic Type ์ง์
let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
// SwiftUI์์๋ ์๋ ์ง์!
Text("์๋
ํ์ธ์")
.font(.body) // ์๋์ผ๋ก Dynamic Type ์ ์ฉ๋จ
// ์ปค์คํ
ํฐํธ์์ Dynamic Type ์ง์
Text("์ปค์คํ
ํฐํธ")
.font(.custom("MyFont", size: 17, relativeTo: .body))
// ์ต์/์ต๋ ํฌ๊ธฐ ์ ํ
Text("์ ํ๋ ํฌ๊ธฐ")
.font(.body)
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
๐ก Dynamic Type ํ ์คํธํ๊ธฐ
์ค์ > ์์ฌ์ด ์ฌ์ฉ > ๋์คํ๋ ์ด ๋ฐ ํ
์คํธ ํฌ๊ธฐ > ๋ ํฐ ํ
์คํธ
์ฌ๊ธฐ์ ํ
์คํธ ํฌ๊ธฐ๋ฅผ ์ต๋๋ก ํค์์ ์ฑ์ ํ
์คํธํด๋ด. ๋ ์ด์์์ด ๊นจ์ง์ง ์๋์ง ํ์ธํ๋ ๊ฒ ์ค์ํด!
โจ๏ธ ํค๋ณด๋ ๋ฐ ํฌ์ปค์ค ๊ด๋ฆฌ
์ด๋ ์ฅ์ ๊ฐ ์๋ ์ฌ์ฉ์๋ค์ ํฐ์น์คํฌ๋ฆฐ ์ฌ์ฉ์ด ์ด๋ ค์ธ ์ ์์ด. ์ธ๋ถ ํค๋ณด๋๋ ์ค์์น ์ปจํธ๋กค์ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ๊ฐ ๋ง์ง. ๊ทธ๋์ ํค๋ณด๋ ๋ด๋น๊ฒ์ด์ ๊ณผ ํฌ์ปค์ค ๊ด๋ฆฌ๊ฐ ์ค์ํด! โจ๏ธ
// UIKit์์ ํฌ์ปค์ค ์์ ์ปค์คํฐ๋ง์ด์ง
class CustomViewController: UIViewController {
let usernameField = UITextField()
let passwordField = UITextField()
let loginButton = UIButton()
override var preferredFocusEnvironments: [UIFocusEnvironment] {
// ์ฒ์ ํฌ์ปค์ค๋ฅผ ๋ฐ์ ์์ ์ง์
return [usernameField]
}
override func didUpdateFocus(in context: UIFocusUpdateContext,
with coordinator: UIFocusAnimationCoordinator) {
super.didUpdateFocus(in: context, with: coordinator)
// ํฌ์ปค์ค ๋ณ๊ฒฝ ์ ์ ๋๋ฉ์ด์
์ถ๊ฐ
coordinator.addCoordinatedAnimations({
context.nextFocusedView?.layer.borderWidth = 2
context.nextFocusedView?.layer.borderColor = UIColor.blue.cgColor
context.previouslyFocusedView?.layer.borderWidth = 0
}, completion: nil)
}
}
// SwiftUI์์ ํฌ์ปค์ค ๊ด๋ฆฌ
struct LoginForm: View {
@FocusState private var focusedField: Field?
@State private var username = ""
@State private var password = ""
enum Field {
case username, password
}
var body: some View {
VStack {
TextField("์ฌ์ฉ์ ์ด๋ฆ", text: $username)
.focused($focusedField, equals: .username)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
SecureField("๋น๋ฐ๋ฒํธ", text: $password)
.focused($focusedField, equals: .password)
.submitLabel(.done)
.onSubmit {
login()
}
Button("๋ก๊ทธ์ธ", action: login)
}
.onAppear {
// ํ๋ฉด ํ์ ์ ์ฒซ ํ๋์ ํฌ์ปค์ค
focusedField = .username
}
}
func login() {
// ๋ก๊ทธ์ธ ๋ก์ง
}
}
VoiceOver ์ฌ์ฉ์๋ฅผ ์ํด ์ปค์คํ ์ก์ ์ ์ถ๊ฐํ ์ ์์ด. ๋กํฐ(Rotor) ๋ฉ๋ด์์ ์ ํํ ์ ์๋ ์ถ๊ฐ ๋์๋ค์ด์ผ.
// UIKit์์ ์ปค์คํ
์ก์
class MessageCell: UITableViewCell {
func setupAccessibility() {
// ๊ธฐ๋ณธ ์ ๊ทผ์ฑ ์ค์
isAccessibilityElement = true
accessibilityLabel = "๊น์ฒ ์๋์ ๋ฉ์์ง"
accessibilityValue = "์๋
ํ์ธ์!"
// ์ปค์คํ
์ก์
์ถ๊ฐ
accessibilityCustomActions = [
UIAccessibilityCustomAction(
name: "๋ต์ฅํ๊ธฐ",
target: self,
selector: #selector(reply)
),
UIAccessibilityCustomAction(
name: "์ญ์ ํ๊ธฐ",
target: self,
selector: #selector(delete)
),
UIAccessibilityCustomAction(
name: "์ ๋ฌํ๊ธฐ",
target: self,
selector: #selector(forward)
)
]
}
@objc func reply() -> Bool {
// ๋ต์ฅ ๋ก์ง
return true // ์ฑ๊ณต ์ true ๋ฐํ
}
@objc func delete() -> Bool {
// ์ญ์ ๋ก์ง
return true
}
@objc func forward() -> Bool {
// ์ ๋ฌ ๋ก์ง
return true
}
}
// SwiftUI์์ ์ปค์คํ
์ก์
struct MessageRow: View {
let message: Message
var body: some View {
Text(message.content)
.accessibilityLabel("\(message.sender)๋์ ๋ฉ์์ง")
.accessibilityValue(message.content)
.accessibilityAction(named: "๋ต์ฅํ๊ธฐ") {
reply()
}
.accessibilityAction(named: "์ญ์ ํ๊ธฐ") {
delete()
}
.accessibilityAction(named: "์ ๋ฌํ๊ธฐ") {
forward()
}
}
func reply() { /* ... */ }
func delete() { /* ... */ }
func forward() { /* ... */ }
}
๐ก ์ปค์คํ ์ก์ ์ฌ์ฉ ๋ฐฉ๋ฒ
VoiceOver ์ฌ์ฉ์๋ ์์๋ฅผ ์ ํํ ํ ๋กํฐ(๋ ์๊ฐ๋ฝ์ผ๋ก ํ์ )๋ฅผ ์ฌ์ฉํด "์ก์ " ๋ฉ๋ด๋ฅผ ์ฐพ์ ์ ์์ด. ๊ฑฐ๊ธฐ์ ์ปค์คํ ์ก์ ๋ค์ ์ ํํ ์ ์์ง!
๐ฑ ์ค์ ์์ : ์ ๊ทผ์ฑ ์ข์ ํผ ๋ง๋ค๊ธฐ
์ด๋ก ์ ์ถฉ๋ถํ ๋ฐฐ์ ์ผ๋, ์ด์ ์ค์ ์์ ๋ฅผ ๋ง๋ค์ด๋ณผ๊น? ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ์ฌ์ฉํ ์ ์๋ ํ์๊ฐ์ ํผ์ ์ ๊ทผ์ฑ์ ๊ณ ๋ คํด์ ๋ง๋ค์ด๋ณด์! ๐
import SwiftUI
struct AccessibleSignUpForm: View {
@State private var name = ""
@State private var email = ""
@State private var password = ""
@State private var confirmPassword = ""
@State private var agreeToTerms = false
@State private var showingError = false
@State private var errorMessage = ""
@FocusState private var focusedField: Field?
enum Field {
case name, email, password, confirmPassword
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// ํค๋
headerSection
// ์
๋ ฅ ํ๋๋ค
formFields
// ์ฝ๊ด ๋์
termsSection
// ์ ์ถ ๋ฒํผ
submitButton
}
.padding()
}
.alert("์ค๋ฅ", isPresented: $showingError) {
Button("ํ์ธ", role: .cancel) { }
} message: {
Text(errorMessage)
}
}
// MARK: - ํค๋ ์น์
var headerSection: some View {
VStack(spacing: 12) {
Image(systemName: "person.crop.circle.badge.plus")
.font(.system(size: 60))
.foregroundColor(.blue)
.accessibilityHidden(true) // ์ฅ์์ฉ ์ด๋ฏธ์ง
Text("ํ์๊ฐ์
")
.font(.largeTitle)
.fontWeight(.bold)
.accessibilityAddTraits(.isHeader)
Text("์ฌ๋ฅ๋ท์ ์ค์ ๊ฒ์ ํ์ํฉ๋๋ค")
.font(.subheadline)
.foregroundColor(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("ํ์๊ฐ์
ํ๋ฉด. ์ฌ๋ฅ๋ท์ ์ค์ ๊ฒ์ ํ์ํฉ๋๋ค")
}
// MARK: - ํผ ํ๋๋ค
var formFields: some View {
VStack(spacing: 16) {
// ์ด๋ฆ ํ๋
AccessibleTextField(
title: "์ด๋ฆ",
placeholder: "ํ๊ธธ๋",
text: $name,
focusedField: $focusedField,
field: .name,
keyboardType: .default,
textContentType: .name
)
.submitLabel(.next)
.onSubmit { focusedField = .email }
// ์ด๋ฉ์ผ ํ๋
AccessibleTextField(
title: "์ด๋ฉ์ผ",
placeholder: "example@email.com",
text: $email,
focusedField: $focusedField,
field: .email,
keyboardType: .emailAddress,
textContentType: .emailAddress
)
.submitLabel(.next)
.onSubmit { focusedField = .password }
// ๋น๋ฐ๋ฒํธ ํ๋
AccessibleSecureField(
title: "๋น๋ฐ๋ฒํธ",
placeholder: "8์ ์ด์ ์
๋ ฅ",
text: $password,
focusedField: $focusedField,
field: .password
)
.submitLabel(.next)
.onSubmit { focusedField = .confirmPassword }
// ๋น๋ฐ๋ฒํธ ํ์ธ ํ๋
AccessibleSecureField(
title: "๋น๋ฐ๋ฒํธ ํ์ธ",
placeholder: "๋น๋ฐ๋ฒํธ ์ฌ์
๋ ฅ",
text: $confirmPassword,
focusedField: $focusedField,
field: .confirmPassword
)
.submitLabel(.done)
.onSubmit { validateAndSubmit() }
// ๋น๋ฐ๋ฒํธ ๊ฐ๋ ํ์
if !password.isEmpty {
PasswordStrengthIndicator(password: password)
}
}
}
// MARK: - ์ฝ๊ด ๋์ ์น์
var termsSection: some View {
Button(action: { agreeToTerms.toggle() }) {
HStack(spacing: 12) {
Image(systemName: agreeToTerms ? "checkmark.square.fill" : "square")
.font(.title2)
.foregroundColor(agreeToTerms ? .blue : .gray)
Text("์ด์ฉ์ฝ๊ด ๋ฐ ๊ฐ์ธ์ ๋ณด์ฒ๋ฆฌ๋ฐฉ์นจ์ ๋์ํฉ๋๋ค")
.font(.body)
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("์ด์ฉ์ฝ๊ด ๋ฐ ๊ฐ์ธ์ ๋ณด์ฒ๋ฆฌ๋ฐฉ์นจ ๋์")
.accessibilityValue(agreeToTerms ? "๋์ํจ" : "๋์ํ์ง ์์")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ ๋์ ์ํ๋ฅผ ๋ณ๊ฒฝํ์ธ์")
.accessibilityAddTraits(.isButton)
}
// MARK: - ์ ์ถ ๋ฒํผ
var submitButton: some View {
Button(action: validateAndSubmit) {
Text("๊ฐ์
ํ๊ธฐ")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFormValid ? Color.blue : Color.gray)
)
}
.disabled(!isFormValid)
.accessibilityLabel("๊ฐ์
ํ๊ธฐ ๋ฒํผ")
.accessibilityHint(isFormValid ?
"๋ ๋ฒ ํญํ์ฌ ํ์๊ฐ์
์ ์๋ฃํ์ธ์" :
"๋ชจ๋ ํ๋๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ์
๋ ฅํ๊ณ ์ฝ๊ด์ ๋์ํด์ผ ํฉ๋๋ค")
.accessibilityAddTraits(isFormValid ? [] : .notEnabled)
}
// MARK: - ์ ํจ์ฑ ๊ฒ์ฌ
var isFormValid: Bool {
!name.isEmpty &&
isValidEmail(email) &&
password.count >= 8 &&
password == confirmPassword &&
agreeToTerms
}
func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegex)
return emailPredicate.evaluate(with: email)
}
func validateAndSubmit() {
if name.isEmpty {
showError("์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์")
} else if !isValidEmail(email) {
showError("์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ์ฃผ์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์")
} else if password.count < 8 {
showError("๋น๋ฐ๋ฒํธ๋ 8์ ์ด์์ด์ด์ผ ํฉ๋๋ค")
} else if password != confirmPassword {
showError("๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค")
} else if !agreeToTerms {
showError("์ด์ฉ์ฝ๊ด์ ๋์ํด์ฃผ์ธ์")
} else {
// ํ์๊ฐ์
์ฒ๋ฆฌ
submitForm()
}
}
func showError(_ message: String) {
errorMessage = message
showingError = true
// VoiceOver ์ฌ์ฉ์๋ฅผ ์ํ ์ฆ๊ฐ์ ์ธ ํผ๋๋ฐฑ
UIAccessibility.post(
notification: .announcement,
argument: "์ค๋ฅ: \(message)"
)
}
func submitForm() {
// ์ค์ ํ์๊ฐ์
๋ก์ง
print("ํ์๊ฐ์
์ฒ๋ฆฌ: \(name), \(email)")
// ์ฑ๊ณต ์๋ฆผ
UIAccessibility.post(
notification: .announcement,
argument: "ํ์๊ฐ์
์ด ์๋ฃ๋์์ต๋๋ค"
)
}
}
// MARK: - ์ ๊ทผ์ฑ ์ข์ ํ
์คํธ ํ๋
struct AccessibleTextField: View {
let title: String
let placeholder: String
@Binding var text: String
var focusedField: FocusState<AccessibleSignUpForm.Field?>.Binding
let field: AccessibleSignUpForm.Field
var keyboardType: UIKeyboardType = .default
var textContentType: UITextContentType?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.accessibilityHidden(true) // ๋ ์ด๋ธ์ ํฌํจ๋ ๊ฒ์ด๋ฏ๋ก
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.keyboardType(keyboardType)
.textContentType(textContentType)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused(focusedField, equals: field)
.accessibilityLabel("\(title) ์
๋ ฅ")
.accessibilityValue(text.isEmpty ? "๋น์ด์์" : text)
.accessibilityHint("\(title)์ ์
๋ ฅํ์ธ์")
}
}
}
// MARK: - ์ ๊ทผ์ฑ ์ข์ ๋ณด์ ํ๋
struct AccessibleSecureField: View {
let title: String
let placeholder: String
@Binding var text: String
var focusedField: FocusState<AccessibleSignUpForm.Field?>.Binding
let field: AccessibleSignUpForm.Field
@State private var isSecure = true
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.accessibilityHidden(true)
HStack {
if isSecure {
SecureField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.focused(focusedField, equals: field)
} else {
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.focused(focusedField, equals: field)
}
Button(action: { isSecure.toggle() }) {
Image(systemName: isSecure ? "eye.slash" : "eye")
.foregroundColor(.gray)
}
.accessibilityLabel(isSecure ? "๋น๋ฐ๋ฒํธ ๋ณด๊ธฐ" : "๋น๋ฐ๋ฒํธ ์จ๊ธฐ๊ธฐ")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ ๋น๋ฐ๋ฒํธ ํ์ ์ํ๋ฅผ ๋ณ๊ฒฝํ์ธ์")
}
.accessibilityElement(children: .contain)
.accessibilityLabel("\(title) ์
๋ ฅ")
.accessibilityValue(text.isEmpty ? "๋น์ด์์" : "์
๋ ฅ๋จ")
}
}
}
// MARK: - ๋น๋ฐ๋ฒํธ ๊ฐ๋ ํ์๊ธฐ
struct PasswordStrengthIndicator: View {
let password: String
var strength: (level: Int, text: String, color: Color) {
let length = password.count
let hasUppercase = password.range(of: "[A-Z]", options: .regularExpression) != nil
let hasLowercase = password.range(of: "[a-z]", options: .regularExpression) != nil
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
let hasSpecial = password.range(of: "[^A-Za-z0-9]", options: .regularExpression) != nil
var score = 0
if length >= 8 { score += 1 }
if length >= 12 { score += 1 }
if hasUppercase && hasLowercase { score += 1 }
if hasNumber { score += 1 }
if hasSpecial { score += 1 }
switch score {
case 0...1: return (1, "์ฝํจ", .red)
case 2...3: return (2, "๋ณดํต", .orange)
case 4: return (3, "๊ฐํจ", .green)
default: return (4, "๋งค์ฐ ๊ฐํจ", .blue)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 4) {
ForEach(0..<4) { index in
Rectangle()
.fill(index < strength.level ? strength.color : Color.gray.opacity(0.3))
.frame(height: 4)
}
}
Text("๋น๋ฐ๋ฒํธ ๊ฐ๋: \(strength.text)")
.font(.caption)
.foregroundColor(strength.color)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("๋น๋ฐ๋ฒํธ ๊ฐ๋: \(strength.text)")
.accessibilityValue("4๋จ๊ณ ์ค \(strength.level)๋จ๊ณ")
}
}
์! ์ฝ๋๊ฐ ์ข ๊ธธ์ง? ํ์ง๋ง ์ด ์์ ์๋ ์ ๊ทผ์ฑ์ ๋ชจ๋ ํต์ฌ ์์๊ฐ ๋ค์ด์์ด:
โ
๋ช
ํํ ๋ ์ด๋ธ๊ณผ ํํธ: ๋ชจ๋ ์
๋ ฅ ํ๋์ ์ค๋ช
์ ๊ณต
โ
ํฌ์ปค์ค ๊ด๋ฆฌ: ํค๋ณด๋ ๋ด๋น๊ฒ์ด์
์ง์
โ
์ํ ํผ๋๋ฐฑ: ์ฒดํฌ๋ฐ์ค ์ํ๋ฅผ ์์ฑ์ผ๋ก ์๋ฆผ
โ
์ค๋ฅ ์ฒ๋ฆฌ: UIAccessibility.post๋ก ์ฆ๊ฐ์ ์ธ ํผ๋๋ฐฑ
โ
๋น๋ฐ๋ฒํธ ํ์/์จ๊ธฐ๊ธฐ: ์ ์๋ ฅ ์ฌ์ฉ์๋ฅผ ์ํ ์ต์
โ
์๊ฐ์ ํผ๋๋ฐฑ: ๋น๋ฐ๋ฒํธ ๊ฐ๋๋ฅผ ์์๊ณผ ํ
์คํธ๋ก ํ์
โ
Dynamic Type: ์๋์ผ๋ก ํ
์คํธ ํฌ๊ธฐ ์กฐ์
๐งช ์ ๊ทผ์ฑ ํ ์คํธํ๊ธฐ
์ ๊ทผ์ฑ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ค๋ฉด, ์ ๋๋ก ์๋ํ๋์ง ํ ์คํธํด์ผ๊ฒ ์ง? ์ฌ๋ฌ ๊ฐ์ง ๋ฐฉ๋ฒ์ด ์์ด! ๐
Xcode์ ๋ด์ฅ๋ ๊ฐ๋ ฅํ ๋๊ตฌ์ผ. ์ค์๊ฐ์ผ๋ก ์ ๊ทผ์ฑ ๋ฌธ์ ๋ฅผ ์ฐพ์์ค.
์ฌ์ฉ ๋ฐฉ๋ฒ:
1. Xcode ๋ฉ๋ด > Xcode > Open Developer Tool > Accessibility Inspector
2. ์๋ฎฌ๋ ์ดํฐ๋ ์ค์ ๊ธฐ๊ธฐ ์ ํ
3. Inspection ํญ์์ ์์ ์ ํํ์ฌ ์ ๊ทผ์ฑ ์์ฑ ํ์ธ
4. Audit ํญ์์ ์๋ ๊ฒ์ฌ ์คํ
๐ก Inspector๊ฐ ์ฐพ์์ฃผ๋ ๋ฌธ์ ๋ค:
โข ๋ ์ด๋ธ์ด ์๋ ์์
โข ํฐ์น ์์ญ์ด ๋๋ฌด ์์ ๋ฒํผ (์ต์ 44x44pt ๊ถ์ฅ)
โข ์์ ๋๋น๊ฐ ๋ถ์กฑํ ํ
์คํธ
โข ์ค๋ณต๋ ์ ๊ทผ์ฑ ๋ ์ด๋ธ
โข Dynamic Type ๋ฏธ์ง์ ํ
์คํธ
์๋ฎฌ๋ ์ดํฐ๋ ์ข์ง๋ง, ์ค์ ๊ธฐ๊ธฐ์์ VoiceOver๋ฅผ ์ผ๊ณ ํ ์คํธํ๋ ๊ฒ ์ต๊ณ ์ผ!
// ํ
์คํธ ์ฒดํฌ๋ฆฌ์คํธ
โ
VoiceOver ํ
์คํธ
- ๋ชจ๋ ํ๋ฉด์ VoiceOver๋ก ํ์ ๊ฐ๋ฅํ๊ฐ?
- ๋ ์ด๋ธ์ด ๋ช
ํํ๊ณ ์ดํดํ๊ธฐ ์ฌ์ด๊ฐ?
- ๋ฒํผ๊ณผ ๋งํฌ๊ฐ ๋ช
ํํ ๊ตฌ๋ถ๋๋๊ฐ?
- ์ปค์คํ
์ก์
์ด ์ ๋๋ก ์๋ํ๋๊ฐ?
โ
์์ ๋ฐ ๋๋น ํ
์คํธ
- ๋คํฌ ๋ชจ๋์์๋ ์ ๋ณด์ด๋๊ฐ?
- ์๋งน ๋ชจ๋์์ ์ ๋ณด๋ฅผ ๊ตฌ๋ถํ ์ ์๋๊ฐ?
- ๋ชจ๋ ํ
์คํธ๊ฐ WCAG AA ๊ธฐ์ค์ ๋ง์กฑํ๋๊ฐ?
โ
Dynamic Type ํ
์คํธ
- ํ
์คํธ ํฌ๊ธฐ๋ฅผ ์ต๋๋ก ํค์ ์ ๋ ๋ ์ด์์์ด ๊นจ์ง์ง ์๋๊ฐ?
- ๋ชจ๋ ํ
์คํธ๊ฐ ์๋ฆฌ์ง ์๊ณ ํ์๋๋๊ฐ?
โ
ํค๋ณด๋ ๋ด๋น๊ฒ์ด์
ํ
์คํธ
- ์ธ๋ถ ํค๋ณด๋๋ก ๋ชจ๋ ๊ธฐ๋ฅ์ ์ฌ์ฉํ ์ ์๋๊ฐ?
- Tab ํค๋ก ๋
ผ๋ฆฌ์ ์ธ ์์๋ก ์ด๋ํ๋๊ฐ?
- ํฌ์ปค์ค๊ฐ ๋ช
ํํ ํ์๋๋๊ฐ?
โ
Switch Control ํ
์คํธ
- ์ค์์น ์ปจํธ๋กค๋ก ์ฑ์ ์ฌ์ฉํ ์ ์๋๊ฐ?
- ๋ชจ๋ ์ธํฐ๋ํฐ๋ธ ์์์ ์ ๊ทผ ๊ฐ๋ฅํ๊ฐ?
XCTest๋ฅผ ์ฌ์ฉํด์ ์ ๊ทผ์ฑ ํ ์คํธ๋ฅผ ์๋ํํ ์ ์์ด!
import XCTest
class AccessibilityTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
// ๋ชจ๋ ๋ฒํผ์ ๋ ์ด๋ธ์ด ์๋์ง ํ์ธ
func testAllButtonsHaveLabels() {
let buttons = app.buttons.allElementsBoundByIndex
for button in buttons {
XCTAssertFalse(
button.label.isEmpty,
"๋ฒํผ์ ์ ๊ทผ์ฑ ๋ ์ด๋ธ์ด ์์ต๋๋ค: \(button)"
)
}
}
// ํฐ์น ์์ญ ํฌ๊ธฐ ํ์ธ
func testButtonSizeIsAccessible() {
let loginButton = app.buttons["๋ก๊ทธ์ธ"]
let frame = loginButton.frame
XCTAssertGreaterThanOrEqual(
frame.width,
44,
"๋ฒํผ ๋๋น๊ฐ ์ต์ ๊ถ์ฅ ํฌ๊ธฐ(44pt)๋ณด๋ค ์์ต๋๋ค"
)
XCTAssertGreaterThanOrEqual(
frame.height,
44,
"๋ฒํผ ๋์ด๊ฐ ์ต์ ๊ถ์ฅ ํฌ๊ธฐ(44pt)๋ณด๋ค ์์ต๋๋ค"
)
}
// VoiceOver๋ก ํ๋ฉด ํ์ ๊ฐ๋ฅํ์ง ํ์ธ
func testVoiceOverNavigation() {
// VoiceOver ํ์ฑํ
app.activate()
let usernameField = app.textFields["์ฌ์ฉ์ ์ด๋ฆ ์
๋ ฅ"]
XCTAssertTrue(usernameField.exists)
XCTAssertTrue(usernameField.isAccessibilityElement)
let passwordField = app.secureTextFields["๋น๋ฐ๋ฒํธ ์
๋ ฅ"]
XCTAssertTrue(passwordField.exists)
XCTAssertTrue(passwordField.isAccessibilityElement)
let loginButton = app.buttons["๋ก๊ทธ์ธ ๋ฒํผ"]
XCTAssertTrue(loginButton.exists)
XCTAssertTrue(loginButton.isAccessibilityElement)
}
// ์ค๋ฅ ๋ฉ์์ง๊ฐ ์ ๊ทผ ๊ฐ๋ฅํ์ง ํ์ธ
func testErrorMessageAccessibility() {
let loginButton = app.buttons["๋ก๊ทธ์ธ ๋ฒํผ"]
loginButton.tap()
// ์ค๋ฅ ๋ฉ์์ง๊ฐ ํ์๋๋์ง ํ์ธ
let errorAlert = app.alerts.firstMatch
XCTAssertTrue(errorAlert.waitForExistence(timeout: 2))
// ์ค๋ฅ ๋ฉ์์ง์ ์ ๊ทผ ๊ฐ๋ฅํ์ง ํ์ธ
XCTAssertTrue(errorAlert.isAccessibilityElement)
XCTAssertFalse(errorAlert.label.isEmpty)
}
// Dynamic Type ์ง์ ํ์ธ
func testDynamicTypeSupport() {
// ํฐ ํ
์คํธ ํฌ๊ธฐ๋ก ์ค์
app.launchArguments.append("-UIPreferredContentSizeCategoryName")
app.launchArguments.append("UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge")
app.launch()
let titleLabel = app.staticTexts["ํ์๊ฐ์
"]
XCTAssertTrue(titleLabel.exists)
// ํ
์คํธ๊ฐ ์๋ฆฌ์ง ์์๋์ง ํ์ธ
XCTAssertFalse(titleLabel.label.isEmpty)
}
}
๐ฏ ์ค๋ฌด์์์ ์ ๊ทผ์ฑ ์ฒดํฌ๋ฆฌ์คํธ
ํ๋ก์ ํธ์์ ๋ฐ๋ก ์ฌ์ฉํ ์ ์๋ ์ค์ ์ฒดํฌ๋ฆฌ์คํธ๋ฅผ ๋ง๋ค์ด๋ดค์ด! ์ด๊ฑธ ํ๊ณผ ๊ณต์ ํ๋ฉด ์ข์ ๊ฑฐ์ผ. ๐
๐ ์์ธ ์ฒดํฌ๋ฆฌ์คํธ
1. ์ฝํ
์ธ ์ ๊ทผ์ฑ
โก ๋ชจ๋ ์ด๋ฏธ์ง์ ๋์ฒด ํ
์คํธ ์ ๊ณต
โก ๋น๋์ค์ ์๋ง ์ ๊ณต
โก ์ค๋์ค์ ๋๋ณธ ์ ๊ณต
โก ๋ณต์กํ ๊ทธ๋ํ/์ฐจํธ์ ํ
์คํธ ์ค๋ช
์ถ๊ฐ
โก ๋งํฌ ํ
์คํธ๊ฐ ๋ชฉ์ ์ ๋ช
ํํ ์ค๋ช
2. ๋ด๋น๊ฒ์ด์
โก ์ผ๊ด๋ ๋ด๋น๊ฒ์ด์
๊ตฌ์กฐ
โก ๊ฑด๋๋ฐ๊ธฐ ๋งํฌ ์ ๊ณต (๊ธด ๋ชฉ๋ก์ ๊ฒฝ์ฐ)
โก ํ์ฌ ์์น ํ์
โก ๋ค๋ก ๊ฐ๊ธฐ ์ ์ค์ฒ ์ง์
โก ๊ฒ์ ๊ธฐ๋ฅ ์ ๊ณต
3. ํผ๊ณผ ์
๋ ฅ
โก ๋ชจ๋ ์
๋ ฅ ํ๋์ ๋ ์ด๋ธ
โก ์ค๋ฅ ๋ฉ์์ง๊ฐ ๋ช
ํํ๊ณ ๊ตฌ์ฒด์
โก ํ์ ํ๋ ๋ช
์
โก ์
๋ ฅ ํ์ ์๋ด
โก ์๋ ์์ฑ ์ง์
4. ํ์ด๋ฐ
โก ์๋ ์ฌ์ ์ฝํ
์ธ ์ ์ด ๊ฐ๋ฅ
โก ํ์์์ ๊ฒฝ๊ณ ๋ฐ ์ฐ์ฅ ์ต์
โก ์ ๋๋ฉ์ด์
์ผ์์ ์ง ๊ฐ๋ฅ
โก ์๋ ์
๋ฐ์ดํธ ์๋ฆผ
5. ์ค๋ฅ ์ฒ๋ฆฌ
โก ์ค๋ฅ ๋ฐ์ ์ ๋ช
ํํ ๋ฉ์์ง
โก ์ค๋ฅ ์์ ๋ฐฉ๋ฒ ์ ์
โก ์ค์ํ ์์
์ ํ์ธ ์์ฒญ
โก ์คํ ์ทจ์ ๊ธฐ๋ฅ ์ ๊ณต
๐ ๊ตญ์ ํ์ ์ ๊ทผ์ฑ
์ ๊ทผ์ฑ์ ๊ตญ์ ํ(Internationalization)์๋ ๋ฐ์ ํ ๊ด๊ณ๊ฐ ์์ด. ๋ค์ํ ์ธ์ด์ ๋ฌธํ๊ถ์ ์ฌ์ฉ์๋ค์ ๊ณ ๋ คํด์ผ ํ๊ฑฐ๋ ! ๐บ๏ธ
์๋์ด, ํ๋ธ๋ฆฌ์ด ๊ฐ์ RTL ์ธ์ด๋ฅผ ์ฌ์ฉํ๋ ์ฌ์ฉ์๋ค์ ์ํด ๋ ์ด์์์ด ์๋์ผ๋ก ๋ค์งํ์ผ ํด.
// SwiftUI๋ ์๋์ผ๋ก RTL ์ง์!
HStack {
Image(systemName: "arrow.right")
Text("๋ค์")
}
// RTL ์ธ์ด์์๋ ์๋์ผ๋ก ์ข์ฐ๊ฐ ๋ค์งํ
// ํน์ ์์๋ง RTL ์ ์ฉ ์ ์ธ
Text("12345")
.environment(\.layoutDirection, .leftToRight)
// UIKit์์ RTL ์ง์
let imageView = UIImageView()
imageView.image = UIImage(systemName: "arrow.right")?
.withHorizontallyFlippedOrientation() // RTL์์ ์๋ ๋ค์ง๊ธฐ
// ๋ ์ด์์ ์ ์ฝ ์กฐ๊ฑด
// leading/trailing ์ฌ์ฉ (left/right ๋์ )
label.leadingAnchor.constraint(equalTo: view.leadingAnchor)
label.trailingAnchor.constraint(equalTo: view.trailingAnchor)
// Localizable.strings
// en
"login.button.label" = "Log In";
"login.button.hint" = "Double tap to log in to your account";
// ko
"login.button.label" = "๋ก๊ทธ์ธ";
"login.button.hint" = "๋ ๋ฒ ํญํ์ฌ ๊ณ์ ์ ๋ก๊ทธ์ธํ์ธ์";
// ar (์๋์ด)
"login.button.label" = "ุชุณุฌูู ุงูุฏุฎูู";
"login.button.hint" = "ุงููุฑ ููุฑูุง ู
ุฒุฏูุฌูุง ูุชุณุฌูู ุงูุฏุฎูู";
// Swift ์ฝ๋
button.accessibilityLabel = NSLocalizedString(
"login.button.label",
comment: "Login button accessibility label"
)
button.accessibilityHint = NSLocalizedString(
"login.button.hint",
comment: "Login button accessibility hint"
)
๐ก ํ์งํ ํ
โข ์ ๊ทผ์ฑ ๋ ์ด๋ธ๋ ๋ฐ๋์ ํ์งํํ๊ธฐ
โข ๋ฌธํ์ ๋งฅ๋ฝ ๊ณ ๋ ค (์ ์ค์ฒ, ์์ด์ฝ ์๋ฏธ)
โข ํ
์คํธ ๊ธธ์ด ๋ณํ ๋๋น (๋
์ผ์ด๋ ์์ด๋ณด๋ค 30% ๊ธธ ์ ์์)
โข ์ซ์, ๋ ์ง, ํตํ ํ์ ํ์งํ
โข ์์ฑ ๋ฐ์ ํ
์คํธ (VoiceOver๋ ์ธ์ด๋ณ๋ก ๋ค๋ฅธ ์์ฑ ์ฌ์ฉ)
๐ผ ์ค๋ฌด ์ฌ๋ก: ์ฌ๋ฅ๋ท ์ฑ ์ ๊ทผ์ฑ ๊ฐ์
์, ์ด์ ์ค์ ์ฌ๋ก๋ฅผ ํตํด ๋ฐฐ์ด ๋ด์ฉ์ ์ ์ฉํด๋ณด์! ์ฌ๋ฅ๋ท ๊ฐ์ ์ฌ๋ฅ ๊ณต์ ํ๋ซํผ์์ ์ ๊ทผ์ฑ์ ์ด๋ป๊ฒ ๊ฐ์ ํ ์ ์์๊น? ๐
struct TalentSearchView: View {
@State private var searchText = ""
@State private var selectedCategory: Category?
@State private var talents: [Talent] = []
var body: some View {
NavigationView {
VStack(spacing: 0) {
// ๊ฒ์ ๋ฐ
searchBar
// ์นดํ
๊ณ ๋ฆฌ ํํฐ
categoryFilter
// ๊ฒ์ ๊ฒฐ๊ณผ
searchResults
}
.navigationTitle("์ฌ๋ฅ ์ฐพ๊ธฐ")
}
}
// MARK: - ๊ฒ์ ๋ฐ
var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.accessibilityHidden(true) // ์ฅ์์ฉ ์์ด์ฝ
TextField("์ด๋ค ์ฌ๋ฅ์ ์ฐพ์ผ์๋์?", text: $searchText)
.textFieldStyle(.plain)
.accessibilityLabel("์ฌ๋ฅ ๊ฒ์")
.accessibilityHint("์ฐพ๊ณ ์ถ์ ์ฌ๋ฅ์ ํค์๋๋ฅผ ์
๋ ฅํ์ธ์")
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
.accessibilityLabel("๊ฒ์์ด ์ง์ฐ๊ธฐ")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ ์
๋ ฅํ ๊ฒ์์ด๋ฅผ ์ง์๋๋ค")
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(10)
.padding(.horizontal)
}
// MARK: - ์นดํ
๊ณ ๋ฆฌ ํํฐ
var categoryFilter: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(Category.allCases) { category in
CategoryChip(
category: category,
isSelected: selectedCategory == category,
action: {
if selectedCategory == category {
selectedCategory = nil
} else {
selectedCategory = category
}
}
)
}
}
.padding(.horizontal)
}
.accessibilityElement(children: .contain)
.accessibilityLabel("์นดํ
๊ณ ๋ฆฌ ํํฐ")
.accessibilityHint("์ํ๋ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ ํํ์ฌ ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ํํฐ๋งํ์ธ์")
}
// MARK: - ๊ฒ์ ๊ฒฐ๊ณผ
var searchResults: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(talents) { talent in
TalentCard(talent: talent)
}
}
.padding()
}
.accessibilityElement(children: .contain)
.accessibilityLabel("๊ฒ์ ๊ฒฐ๊ณผ")
.accessibilityValue("\(talents.count)๊ฐ์ ์ฌ๋ฅ์ด ๊ฒ์๋์์ต๋๋ค")
}
}
// MARK: - ์นดํ
๊ณ ๋ฆฌ ์นฉ
struct CategoryChip: View {
let category: Category
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text(category.icon)
.accessibilityHidden(true)
Text(category.name)
.font(.subheadline)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Capsule()
.fill(isSelected ? Color.blue : Color(.systemGray5))
)
.foregroundColor(isSelected ? .white : .primary)
}
.accessibilityLabel(category.name)
.accessibilityValue(isSelected ? "์ ํ๋จ" : "์ ํ ์ ๋จ")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ \(category.name) ์นดํ
๊ณ ๋ฆฌ๋ฅผ \(isSelected ? "์ ํ ํด์ " : "์ ํ")ํฉ๋๋ค")
.accessibilityAddTraits(.isButton)
.accessibilityRemoveTraits(isSelected ? [] : .isSelected)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
// MARK: - ์ฌ๋ฅ ์นด๋
struct TalentCard: View {
let talent: Talent
var body: some View {
Button(action: { /* ์์ธ ํ์ด์ง๋ก ์ด๋ */ }) {
HStack(spacing: 16) {
// ํ๋กํ ์ด๋ฏธ์ง
AsyncImage(url: talent.profileImageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 12))
.accessibilityHidden(true) // ์ด๋ฏธ์ง๋ ๋ ์ด๋ธ์ ํฌํจ
VStack(alignment: .leading, spacing: 8) {
// ์ฌ๋ฅ ์ ๋ชฉ
Text(talent.title)
.font(.headline)
.foregroundColor(.primary)
.lineLimit(2)
// ์ ๊ณต์ ์ ๋ณด
HStack(spacing: 4) {
Image(systemName: "person.fill")
.font(.caption)
Text(talent.providerName)
.font(.subheadline)
}
.foregroundColor(.secondary)
// ํ์
HStack(spacing: 4) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundColor(.yellow)
Text(String(format: "%.1f", talent.rating))
.font(.subheadline)
Text("(\(talent.reviewCount))")
.font(.caption)
.foregroundColor(.secondary)
}
// ๊ฐ๊ฒฉ
Text(talent.priceText)
.font(.headline)
.foregroundColor(.blue)
}
Spacer()
// ์ฐํ๊ธฐ ๋ฒํผ
Button(action: { /* ์ฐํ๊ธฐ ํ ๊ธ */ }) {
Image(systemName: talent.isFavorite ? "heart.fill" : "heart")
.font(.title3)
.foregroundColor(talent.isFavorite ? .red : .gray)
}
.accessibilityLabel(talent.isFavorite ? "์ฐ ํด์ " : "์ฐํ๊ธฐ")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ ๊ด์ฌ ๋ชฉ๋ก์ \(talent.isFavorite ? "์ ๊ฑฐ" : "์ถ๊ฐ")ํฉ๋๋ค")
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("""
\(talent.title). \
์ ๊ณต์: \(talent.providerName). \
ํ์ : \(String(format: "%.1f", talent.rating))์ , \
๋ฆฌ๋ทฐ \(talent.reviewCount)๊ฐ. \
๊ฐ๊ฒฉ: \(talent.priceText)
""")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ ์์ธ ์ ๋ณด๋ฅผ ํ์ธํ์ธ์")
.accessibilityAction(named: "์ฐํ๊ธฐ") {
// ์ฐํ๊ธฐ ์ก์
}
}
}
// MARK: - ๋ชจ๋ธ
struct Talent: Identifiable {
let id: UUID
let title: String
let providerName: String
let profileImageURL: URL?
let rating: Double
let reviewCount: Int
let priceText: String
var isFavorite: Bool
}
enum Category: String, CaseIterable, Identifiable {
case design = "๋์์ธ"
case programming = "ํ๋ก๊ทธ๋๋ฐ"
case writing = "๊ธ์ฐ๊ธฐ"
case music = "์์
"
case video = "์์"
case consulting = "์ปจ์คํ
"
var id: String { rawValue }
var name: String { rawValue }
var icon: String {
switch self {
case .design: return "๐จ"
case .programming: return "๐ป"
case .writing: return "โ๏ธ"
case .music: return "๐ต"
case .video: return "๐ฌ"
case .consulting: return "๐ผ"
}
}
}
์ด ์์ ์์ ์ฃผ๋ชฉํ ์ ๋ค:
โ
๊ฒ์ ๋ฐ: ๋ช
ํํ ๋ ์ด๋ธ๊ณผ ํํธ, ๊ฒ์์ด ์ง์ฐ๊ธฐ ๋ฒํผ์ ์ค๋ช
โ
์นดํ
๊ณ ๋ฆฌ ํํฐ: ์ ํ ์ํ๋ฅผ ์์ฑ์ผ๋ก ์๋ฆผ, ํ ๊ธ ๋์ ์ค๋ช
โ
์ฌ๋ฅ ์นด๋: ๋ชจ๋ ์ ๋ณด๋ฅผ ํ๋์ ๋ ์ด๋ธ๋ก ๊ฒฐํฉ, ์ปค์คํ
์ก์
์ ๊ณต
โ
์ฐํ๊ธฐ ๋ฒํผ: ๋ณ๋์ ์ ๊ทผ์ฑ ์์๋ก ๋ถ๋ฆฌ, ํ์ฌ ์ํ ๋ช
ํํ ์ ๋ฌ
โ
์ด๋ฏธ์ง: ์ฅ์์ฉ ์ด๋ฏธ์ง๋ ์จ๊ธฐ๊ณ , ์ค์ํ ์ ๋ณด๋ ํ
์คํธ๋ก ์ ๊ณต
๐ ์ ๊ทผ์ฑ ๋ชจ๋ฒ ์ฌ๋ก์ ์ํฐํจํด
๋ง์ง๋ง์ผ๋ก, ์ค๋ฌด์์ ์์ฃผ ๋ฐ์ํ๋ ์ค์๋ค๊ณผ ์ฌ๋ฐ๋ฅธ ๋ฐฉ๋ฒ์ ์ ๋ฆฌํด๋ณผ๊ฒ! ๐ฏ
1. ์๋ฏธ ์๋ ๋ ์ด๋ธ ์์ฑ
// โ ๋์ ์
button.accessibilityLabel = "๋ฒํผ"
button.accessibilityLabel = "ํด๋ฆญ"
button.accessibilityLabel = "์ฌ๊ธฐ"
// โ
์ข์ ์
button.accessibilityLabel = "์ฅ๋ฐ๊ตฌ๋์ ์ถ๊ฐ"
button.accessibilityLabel = "ํ๋กํ ํธ์ง"
button.accessibilityLabel = "๋ฉ์์ง ๋ณด๋ด๊ธฐ"
2. ์ค๋ณต ์ ๋ณด ์ ๊ฑฐ
// โ ๋์ ์
button.accessibilityLabel = "๋ก๊ทธ์ธ ๋ฒํผ"
button.accessibilityTraits = .button
// VoiceOver: "๋ก๊ทธ์ธ ๋ฒํผ, ๋ฒํผ" (์ค๋ณต!)
// โ
์ข์ ์
button.accessibilityLabel = "๋ก๊ทธ์ธ"
button.accessibilityTraits = .button
// VoiceOver: "๋ก๊ทธ์ธ, ๋ฒํผ"
3. ๋์ ์ฝํ ์ธ ์ ๋ฐ์ดํธ ์๋ฆผ
// โ
์ข์ ์: ์ฝํ
์ธ ๋ณ๊ฒฝ ์ ์๋ฆผ
func updateCartCount(_ count: Int) {
cartBadge.text = "\(count)"
// VoiceOver ์ฌ์ฉ์์๊ฒ ์ฆ์ ์๋ฆผ
let message = count == 0 ?
"์ฅ๋ฐ๊ตฌ๋๊ฐ ๋น์์ต๋๋ค" :
"์ฅ๋ฐ๊ตฌ๋์ \(count)๊ฐ์ ์ํ์ด ์์ต๋๋ค"
UIAccessibility.post(
notification: .announcement,
argument: message
)
}
4. ๊ทธ๋ฃนํ๋ก ํจ์จ์ฑ ๋์ด๊ธฐ
// โ
์ข์ ์: ๊ด๋ จ ์์ ๊ทธ๋ฃนํ
struct ProductCard: View {
let product: Product
var body: some View {
VStack {
Image(product.imageName)
Text(product.name)
Text(product.price)
Button("๊ตฌ๋งคํ๊ธฐ") { }
}
.accessibilityElement(children: .combine)
.accessibilityLabel("""
\(product.name), \
๊ฐ๊ฒฉ: \(product.price)
""")
.accessibilityAction(named: "๊ตฌ๋งคํ๊ธฐ") {
purchase()
}
}
}
5. ์ํ ๋ณํ ๋ช ํํ ์ ๋ฌ
// โ
์ข์ ์
struct ToggleButton: View {
@State private var isOn = false
var body: some View {
Button(action: { isOn.toggle() }) {
Image(systemName: isOn ? "speaker.wave.3" : "speaker.slash")
}
.accessibilityLabel("์๋ฆฌ")
.accessibilityValue(isOn ? "์ผ์ง" : "๊บผ์ง")
.accessibilityHint("๋ ๋ฒ ํญํ์ฌ ์๋ฆฌ๋ฅผ \(isOn ? "๋๊ธฐ" : "์ผ๊ธฐ")")
.accessibilityAddTraits(.isButton)
}
}
1. ์ด๋ฏธ์ง์ ํ ์คํธ ํฌํจ
// โ ๋์ ์: ์ค์ํ ์ ๋ณด๋ฅผ ์ด๋ฏธ์ง์๋ง ํฌํจ
Image("promotion_banner") // ๋ฐฐ๋์ "50% ํ ์ธ" ํ
์คํธ๊ฐ ์ด๋ฏธ์ง๋ก ํฌํจ
.accessibilityLabel("ํ๋ก๋ชจ์
๋ฐฐ๋")
// VoiceOver ์ฌ์ฉ์๋ ํ ์ธ ์ ๋ณด๋ฅผ ์ ์ ์์!
// โ
์ข์ ์
Image("promotion_banner")
.accessibilityLabel("ํ๋ก๋ชจ์
: ๋ชจ๋ ์ํ 50% ํ ์ธ, ์ค๋ ํ๋ฃจ๋ง")
.accessibilityAddTraits(.isButton)
2. ํฐ์น ์์ญ์ด ๋๋ฌด ์์
// โ ๋์ ์
Button(action: { }) {
Image(systemName: "xmark")
.font(.system(size: 12))
}
.frame(width: 20, height: 20) // ๋๋ฌด ์์!
// โ
์ข์ ์
Button(action: { }) {
Image(systemName: "xmark")
.font(.system(size: 12))
.frame(width: 44, height: 44) // ์ถฉ๋ถํ ํฐ์น ์์ญ
}
3. ์๋ ์ฌ์ ์ฝํ ์ธ ์ ์ด ๋ถ๊ฐ
// โ ๋์ ์
VideoPlayer(player: player)
.onAppear {
player.play() // ์๋ ์ฌ์, ๋ฉ์ถ ์ ์์
}
// โ
์ข์ ์
VStack {
VideoPlayer(player: player)
HStack {
Button("์ฌ์") { player.play() }
Button("์ผ์์ ์ง") { player.pause() }
}
.accessibilityElement(children: .contain)
}
4. ํ์์์์ด ๋๋ฌด ์งง์
// โ ๋์ ์
.alert("์ธ์
๋ง๋ฃ", isPresented: $showTimeout) {
Button("ํ์ธ") { }
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
showTimeout = true // 5์ด ํ ์๋ ๋ก๊ทธ์์
}
}
// โ
์ข์ ์
.alert("์ธ์
๋ง๋ฃ ์์ ", isPresented: $showTimeoutWarning) {
Button("์ฐ์ฅ") { extendSession() }
Button("๋ก๊ทธ์์") { logout() }
}
.onAppear {
// ์ถฉ๋ถํ ์๊ฐ ์ ๊ณต + ๊ฒฝ๊ณ + ์ฐ์ฅ ์ต์
DispatchQueue.main.asyncAfter(deadline: .now() + 60) {
showTimeoutWarning = true
}
}
5. ์ค๋ฅ ๋ฉ์์ง๊ฐ ๋ถ๋ช ํ
// โ ๋์ ์
Text("์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค")
.foregroundColor(.red)
// โ
์ข์ ์
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
Text("๋ก๊ทธ์ธ ์คํจ")
.font(.headline)
}
.foregroundColor(.red)
Text("์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค. ๋ค์ ํ์ธํด์ฃผ์ธ์.")
.font(.body)
Button("๋น๋ฐ๋ฒํธ ์ฐพ๊ธฐ") { }
}
.accessibilityElement(children: .combine)
.accessibilityLabel("์ค๋ฅ: ๋ก๊ทธ์ธ ์คํจ. ์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค")
๐ ์ ๊ทผ์ฑ์ ๋ฏธ๋์ ์๋ก์ด ๊ธฐ์
์ ๊ทผ์ฑ ๊ธฐ์ ์ ๊ณ์ ๋ฐ์ ํ๊ณ ์์ด. iOS 17๊ณผ ์ต์ ๊ธฐ์ ๋ค์ ์ดํด๋ณด์! ๐ฎ
1. Personal Voice (๊ฐ์ธ ์์ฑ)
์ฌ์ฉ์์ ๋ชฉ์๋ฆฌ๋ฅผ ํ์ตํด์ ์์ฑ์ ์์ ์ํ์ด ์๋ ์ฌ๋๋ค์ด ์์ ์ ๋ชฉ์๋ฆฌ๋ก ๊ณ์ ์ํตํ ์ ์๊ฒ ํด์ค.
2. Live Speech (์ค์๊ฐ ์์ฑ)
ํ์ดํํ ๋ด์ฉ์ ์ค์๊ฐ์ผ๋ก ์์ฑ์ผ๋ก ๋ณํํด์ ํตํ๋ ๋๋ฉด ๋ํ์์ ์ฌ์ฉํ ์ ์์ด.
3. Point and Speak (๊ฐ๋ฆฌํค๊ณ ๋งํ๊ธฐ)
์นด๋ฉ๋ผ๋ก ๋ฌผ์ฒด๋ฅผ ๊ฐ๋ฆฌํค๋ฉด ๊ทธ ์์ ํ
์คํธ๋ฅผ ์ฝ์ด์ค. ์ ์๋ ์ธ์ง ๋ฒํผ์ด๋ ์ฝ๋ณ ๋ผ๋ฒจ ์ฝ๊ธฐ์ ์ ์ฉํด!
4. Assistive Access (๋ณด์กฐ ์ ๊ทผ)
์ธ์ง ์ฅ์ ๊ฐ ์๋ ์ฌ์ฉ์๋ฅผ ์ํ ๋จ์ํ๋ ์ธํฐํ์ด์ค.
AI ๊ธฐ์ ์ด ์ ๊ทผ์ฑ์ ํ์ ํ๊ณ ์์ด:
// Vision Framework๋ฅผ ํ์ฉํ ์ด๋ฏธ์ง ์ค๋ช
์์ฑ
import Vision
func generateImageDescription(for image: UIImage) {
guard let cgImage = image.cgImage else { return }
let request = VNRecognizeTextRequest { request, error in
guard let observations = request.results as? [VNRecognizedTextObservation] else {
return
}
let recognizedText = observations.compactMap { observation in
observation.topCandidates(1).first?.string
}.joined(separator: " ")
// VoiceOver์ ์๋ฆผ
UIAccessibility.post(
notification: .announcement,
argument: "์ด๋ฏธ์ง์์ ์ธ์๋ ํ
์คํธ: \(recognizedText)"
)
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
try? handler.perform([request])
}
// Core ML์ ํ์ฉํ ์ด๋ฏธ์ง ๋ถ๋ฅ
import CoreML
func classifyImage(_ image: UIImage) async -> String? {
// ์ฌ์ ํ์ต๋ ๋ชจ๋ธ ์ฌ์ฉ
guard let model = try? VNCoreMLModel(for: MobileNetV2().model) else {
return nil
}
let request = VNCoreMLRequest(model: model)
guard let cgImage = image.cgImage else { return nil }
let handler = VNImageRequestHandler(cgImage: cgImage)
try? handler.perform([request])
guard let results = request.results as? [VNClassificationObservation],
let topResult = results.first else {
return nil
}
return "\(topResult.identifier), ์ ๋ขฐ๋ \(Int(topResult.confidence * 100))%"
}
๐ก ๋ฏธ๋์ ์ ๊ทผ์ฑ ํธ๋ ๋
โข ์์ฑ ์ธํฐํ์ด์ค: Siri์ ์์ฑ ๋ช
๋ น์ ๊ณ ๋ํ
โข ํ
ํฑ ํผ๋๋ฐฑ: ๋ ์ ๊ตํ ์ด๊ฐ ์ ๋ณด ์ ๋ฌ
โข AR ์ ๊ทผ์ฑ: ์ฆ๊ฐํ์ค์ ํ์ฉํ ๋ด๋น๊ฒ์ด์
โข ๋-์ปดํจํฐ ์ธํฐํ์ด์ค: ์๊ฐ๋ง์ผ๋ก ๊ธฐ๊ธฐ ์ ์ด
โข ์ค์๊ฐ ๋ฒ์ญ: ์์ด ์ธ์ ๋ฐ ๋ฒ์ญ
โข ๊ฐ์ธํ๋ AI ์ด์์คํดํธ: ์ฌ์ฉ์์ ํ์์ ๋ง์ถ ๋ง์ถคํ ์ง์
๐ ๋ ๋ฐฐ์ฐ๊ณ ์ถ๋ค๋ฉด?
์ ๊ทผ์ฑ์ ๊ณ์ ๋ฐฐ์์ผ ํ๋ ๋ถ์ผ์ผ. ์ฌ๊ธฐ ์ ์ฉํ ๋ฆฌ์์ค๋ค์ ๋ชจ์๋ดค์ด! ๐
๐ Apple ๊ณต์ ๋ฆฌ์์ค
โข Apple Accessibility
โข Human Interface Guidelines - Accessibility
โข WWDC Accessibility Sessions
โข Accessibility Documentation
๐ ์น ๋ฆฌ์์ค
โข WCAG 2.1 Quick Reference
โข WebAIM
โข The A11Y Project
โข Inclusive Components
๐ ํ์ต ์๋ฃ
โข Ray Wenderlich์ iOS Accessibility Tutorial
โข Hacking with Swift์ Accessibility ๊ฐ์ด๋
โข Paul Hudson์ SwiftUI Accessibility
โข Apple์ Accessibility Inspector ๊ฐ์ด๋
๐๏ธ ํ์บ์คํธ & ์ปค๋ฎค๋ํฐ
โข A11y Rules Podcast
โข iOS Dev Weekly (์ ๊ทผ์ฑ ์น์
)
โข Swift by Sundell
โข iOS Accessibility Slack ์ปค๋ฎค๋ํฐ
๐ฌ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์! ์ ๋ง ๊ธด ์ฌ์ ์ด์์ง? ํ์ง๋ง ์ด์ ๋๋ iOS ์ ๊ทผ์ฑ์ ์ ๋ฌธ๊ฐ๊ฐ ๋์์ด! ๐
์ฐ๋ฆฌ๊ฐ ๋ฐฐ์ด ๊ฒ๋ค์ ์ ๋ฆฌํ๋ฉด:
โ
์ ๊ทผ์ฑ์ ์ ํ์ด ์๋ ํ์
โ
VoiceOver๋ ์๊ฐ์ฅ์ ์ธ์ ๋์งํธ ์ธ์์ผ๋ก ๊ฐ๋ ๋ฌธ
โ
๋ช
ํํ ๋ ์ด๋ธ, ์ ์ ํ Trait, ์ ์ฉํ ํํธ๊ฐ ํต์ฌ
โ
์์ ๋๋น, Dynamic Type, ํค๋ณด๋ ๋ด๋น๊ฒ์ด์
๋ ์ค์
โ
ํ
์คํธ, ํ
์คํธ, ๋ ํ
์คํธ!
โ
์ ๊ทผ์ฑ์ ๋ชจ๋ ์ฌ์ฉ์์๊ฒ ๋ ๋์ ๊ฒฝํ์ ์ ๊ณต
๊ธฐ์ตํด, ์ ๊ทผ์ฑ ์ข์ ์ฑ์ ๋จ์ํ '์ฐฉํ' ์ฑ์ด ์๋์ผ. ๋ ๋ง์ ์ฌ์ฉ์์๊ฒ ๋๋ฌํ๊ณ , ๋ ๋์ ์ฌ์ฉ์ ๊ฒฝํ์ ์ ๊ณตํ๋ฉฐ, ๋ฒ์ ์๊ตฌ์ฌํญ์ ์ถฉ์กฑํ๋ '๋๋ํ' ์ฑ์ด์ผ. ๐ง
์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ๋ค์ํ ์ฌ๋ฅ์ ๊ฐ์ง ์ฌ๋๋ค์ด ๋ชจ์ด๋ฏ์ด, ์ฐ๋ฆฌ๊ฐ ๋ง๋๋ ์ฑ๋ ๋ค์ํ ๋ฅ๋ ฅ์ ๊ฐ์ง ๋ชจ๋ ์ฌ์ฉ์๋ฅผ ํ์ํด์ผ ํด. ๊ทธ๊ฒ ์ง์ ํ ํฌ์ฉ์ ๋์์ธ์ด๊ณ , ์ฐ๋ฆฌ๊ฐ ์ถ๊ตฌํด์ผ ํ ๋ฐฉํฅ์ด์ผ! ๐ช
๐ก ์ค๋๋ถํฐ ์ค์ฒํ ์ ์๋ ๊ฒ๋ค:
1. ๋ค์ ํ๋ก์ ํธ์์ ์ ๊ทผ์ฑ์ ์ฒ์๋ถํฐ ๊ณ ๋ คํ๊ธฐ
2. VoiceOver๋ฅผ ์ผ๊ณ ์์ ์ ์ฑ ์ฌ์ฉํด๋ณด๊ธฐ
3. ํ์๋ค๊ณผ ์ ๊ทผ์ฑ ์ฒดํฌ๋ฆฌ์คํธ ๊ณต์ ํ๊ธฐ
4. Accessibility Inspector๋ก ์ ๊ธฐ์ ์ผ๋ก ๊ฒ์ฌํ๊ธฐ
5. ์ค์ ์ฅ์ ์ธ ์ฌ์ฉ์์ ํผ๋๋ฐฑ ๋ฐ๊ธฐ
์, ์ด์ ๋๊ฐ์ ๋ชจ๋๋ฅผ ์ํ ๋ฉ์ง ์ฑ์ ๋ง๋ค์ด๋ณด์! ๋๋ผ๋ฉด ํ ์ ์์ด! ๐โจ
๊ถ๊ธํ ์ ์ด ์๊ฑฐ๋ ๋์์ด ํ์ํ๋ฉด ์ธ์ ๋ Apple์ ์ ๊ทผ์ฑ ๋ฌธ์๋ฅผ ์ฐธ๊ณ ํ๊ฑฐ๋, ๊ฐ๋ฐ์ ์ปค๋ฎค๋ํฐ์ ์ง๋ฌธํด๋ด. ์ฐ๋ฆฌ ๋ชจ๋ ํจ๊ป ๋ ๋์ ๋์งํธ ์ธ์์ ๋ง๋ค์ด๊ฐ๋ ๊ฑฐ๋๊น! ๐
Happy Coding, and Happy Accessibility! ๐จ๐จโ๐ป๐ฉโ๐ป
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.