diff --git a/Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift b/Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift index 8c64e9dd..8988177c 100644 --- a/Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift +++ b/Projects/Feature/AddWidgetFeature/Sources/AddWidgetView.swift @@ -63,6 +63,7 @@ public struct AddWidgetView: View { VStack(spacing: 0) { widgetPreview(for: widget) .padding(.top, 24) + .accessibilityHidden(true) VStack(spacing: 4) { Text(widget.kind.title) @@ -81,6 +82,8 @@ public struct AddWidgetView: View { .fill(Color.cardBackground) } .padding(.horizontal, 16) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(widget.kind.title), \(widget.family.title)") } } @@ -103,6 +106,7 @@ private struct WidgetGuideView: View { @State private var elapsedTime: Double = 0 @State private var timerActive = true + @AccessibilityFocusState private var isGuideFocused: Bool private let cycleTime: Double = 10.0 var currentGuideText: String { @@ -139,6 +143,8 @@ private struct WidgetGuideView: View { .frame(height: 100, alignment: .top) .padding(.horizontal, 16) .animation(.easeInOut, value: currentGuideText) + .accessibilityFocused($isGuideFocused) + .accessibilityLabel(currentGuideText) .onReceive( Timer.publish( every: 0.5, @@ -170,6 +176,9 @@ private struct WidgetGuideView: View { .onAppear { timerActive = true elapsedTime = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isGuideFocused = true + } } } } diff --git a/Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift b/Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift index dcbe5936..95223b5d 100644 --- a/Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift +++ b/Projects/Feature/AllergySettingFeature/Sources/AllergySettingView.swift @@ -16,39 +16,53 @@ public struct AllergySettingView: View { } public var body: some View { - VStack { - ScrollView { - Spacer() - .frame(height: 20) + let scrollView = ScrollView { + Spacer() + .frame(height: 20) - LazyVGrid(columns: columns, spacing: 8) { - ForEach(AllergyType.allCases.indices, id: \.self) { index in - let allergy = AllergyType.allCases[safe: index] ?? .turbulence - - allergyColumnView(index: index, allergy: allergy) - .onTapGesture { - viewStore.send(.allergyDidSelect(allergy), animation: .default) - } - } - } - .padding(.horizontal, 16) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - if viewStore.allergyDidTap { - TWButton(title: "저장") { - viewStore.send(.saveButtonDidTap) + LazyVGrid(columns: columns, spacing: 8) { + ForEach(AllergyType.allCases.indices, id: \.self) { index in + let allergy = AllergyType.allCases[safe: index] ?? .turbulence + + allergyColumnView(index: index, allergy: allergy) + .onTapGesture { + viewStore.send(.allergyDidSelect(allergy), animation: .default) + } } - .padding(.horizontal, 16) - .padding(.bottom, 8) } + .padding(.horizontal, 16) } + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.backgroundSecondary.ignoresSafeArea()) .navigationTitle("알레르기") .onAppear { viewStore.send(.onAppear) } .twBackButton(dismiss: dismiss) + + if #available(iOS 26.0, *) { + scrollView + .safeAreaBar(edge: .bottom) { + if viewStore.allergyDidTap { + TWButton(title: "저장") { + viewStore.send(.saveButtonDidTap) + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } + } else { + scrollView + .safeAreaInset(edge: .bottom) { + if viewStore.allergyDidTap { + TWButton(title: "저장") { + viewStore.send(.saveButtonDidTap) + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } + } } @ViewBuilder @@ -91,5 +105,8 @@ public struct AllergySettingView: View { .padding(.top, 8) .padding(.trailing, 16) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(allergy.rawValue) + .accessibilityAddTraits(isAllergyContains ? [.isButton, .isSelected] : .isButton) } } diff --git a/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift b/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift index 00ca6805..b6892f41 100644 --- a/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift +++ b/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift @@ -5,7 +5,9 @@ import SwiftUI struct ReviewToast: View { @Dependency(\.featureFlagClient) private var featureFlagClient + @Environment(\.accessibilityReduceTransparency) var reduceTransparency let onTap: () -> Void + let onDismiss: () -> Void var body: some View { let baseButton = Button { @@ -14,6 +16,7 @@ struct ReviewToast: View { HStack(spacing: 8) { Image(systemName: "star.bubble.fill") .foregroundColor(.yellow) + .accessibilityHidden(true) Text(featureFlagClient.getString(.reviewText) ?? "오늘뭐임을 더 발전시킬 수 있게 리뷰 부탁드려요!") .font(.system(size: 14, weight: .medium)) @@ -22,8 +25,11 @@ struct ReviewToast: View { .padding(.horizontal, 16) .padding(.vertical, 12) } + .accessibilityLabel("앱 리뷰 남기기") + .accessibilityHint("탭하면 앱 스토어 리뷰 페이지로 이동합니다") + .accessibilityAction(.escape) { onDismiss() } - if #available(iOS 26.0, *) { + if #available(iOS 26.0, *), !reduceTransparency { baseButton .glassEffect(.regular.interactive(), in: .capsule) } else { diff --git a/Projects/Feature/MainFeature/Sources/MainView.swift b/Projects/Feature/MainFeature/Sources/MainView.swift index d5234208..0931482e 100644 --- a/Projects/Feature/MainFeature/Sources/MainView.swift +++ b/Projects/Feature/MainFeature/Sources/MainView.swift @@ -6,6 +6,7 @@ import NoticeFeature import SettingsFeature import SwiftUI import TimeTableFeature +import UIKit import TipKit import TWLog @@ -14,6 +15,7 @@ public struct MainView: View { @ObservedObject var viewStore: ViewStoreOf @Environment(\.openURL) var openURL @Environment(\.calendar) var calendar + @Environment(\.accessibilityReduceMotion) var reduceMotion @Dependency(\.userDefaultsClient) var userDefaultsClient public init(store: StoreOf) { @@ -28,6 +30,7 @@ public struct MainView: View { .padding(.horizontal, 16) .padding(.vertical, 12) .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isStaticText) .accessibilityLabel({ let school: String = viewStore.school let grade: String = viewStore.grade @@ -47,8 +50,6 @@ public struct MainView: View { items: ["급식", "시간표"] ) .padding(.top, 32) - .accessibilityLabel("메뉴 탭") - .accessibilityHint("급식과 시간표 중 원하는 메뉴를 선택할 수 있습니다.") ZStack(alignment: .bottomTrailing) { TabView( @@ -86,18 +87,42 @@ public struct MainView: View { Color.backgroundSecondary .ignoresSafeArea() } + .onChange(of: viewStore.currentTab) { newTab in + let tabName = newTab == 0 ? "급식" : "시간표" + UIAccessibility.post( + notification: .announcement, + argument: "\(tabName) 탭" + ) + } if viewStore.isShowingReviewToast { - ReviewToast { - viewStore.send(.requestReview) - TWLog.event(ClickReviewEventLog()) - } + ReviewToast( + onTap: { + viewStore.send(.requestReview) + TWLog.event(ClickReviewEventLog()) + }, + onDismiss: { + viewStore.send(.hideReviewToast, animation: .default) + } + ) .frame(maxWidth: .infinity) .padding(.horizontal, 16) .padding(.bottom, 24) - .animation(.default, value: viewStore.isShowingReviewToast) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation( + reduceMotion ? .none : .default, + value: viewStore.isShowingReviewToast + ) + .transition( + reduceMotion + ? .opacity + : .move(edge: .bottom).combined(with: .opacity) + ) .onAppear { + UIAccessibility.post( + notification: .announcement, + argument: "앱 리뷰 요청이 표시되었습니다" + ) + guard !UIAccessibility.isVoiceOverRunning else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 7.5) { viewStore.send(.hideReviewToast, animation: .default) } @@ -173,7 +198,7 @@ public struct MainView: View { } } .accessibilityLabel("날짜 선택") - .accessibilityHint("클릭하여 날짜를 선택할 수 있습니다") + .accessibilityRemoveTraits(.isButton) } ToolbarItemGroup(placement: .topBarTrailing) { diff --git a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift index d3fbfc99..6adc73d0 100644 --- a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift +++ b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift @@ -153,8 +153,8 @@ public struct WeeklyMealView: View { let calText: String = String(format: "%.1f", subMeal.cal) let calLabel: String = "\(calText) Kcal" let titleText: String = relativeTitle(for: dayMeal.date, mealType: type) - let accessibilityText: String = "\(titleText) \(calText) 칼로리" let mealTexts: [String] = subMeal.meals.map { mealDisplay(meal: $0) } + let accessibilityText: String = "\(titleText) \(calText) 칼로리. \(mealTexts.joined(separator: ", "))" let dateText: String = "\(dayMeal.date.formatted(.dateTime.month().day().weekday(.wide))) \(type.display)" let joinedMeals: String = mealTexts.joined(separator: "\n") let shareText: String = "\(dateText)\n\(joinedMeals)" @@ -213,6 +213,20 @@ public struct WeeklyMealView: View { mealCardView .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityText) + .accessibilityAction(named: "텍스트로 복사") { + UIPasteboard.general.string = shareText + TWLog.event(ShareMealEventLog()) + } + .accessibilityAction(named: "이미지로 복사") { + if #available(iOS 16.0, *) { + let renderer = ImageRenderer(content: mealCardView) + renderer.scale = displayScale + if let image = renderer.uiImage { + UIPasteboard.general.image = image + TWLog.event(ShareMealImageEventLog()) + } + } + } .contextMenu { Button { UIPasteboard.general.string = shareText diff --git a/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift b/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift index 31fa2222..3a0c0411 100644 --- a/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift +++ b/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingView.swift @@ -17,6 +17,7 @@ public struct SchoolSettingView: View { private let isNavigationPushed: Bool @FocusState private var focusField: FocusField? @Environment(\.dismiss) var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion public init( store: StoreOf, @@ -46,9 +47,9 @@ public struct SchoolSettingView: View { schoolSearchResults(viewStore: viewStore) } } - .animation(.default, value: viewStore.grade) - .animation(.default, value: viewStore.class) - .animation(.default, value: viewStore.school) + .animation(reduceMotion ? .none : .default, value: viewStore.grade) + .animation(reduceMotion ? .none : .default, value: viewStore.class) + .animation(reduceMotion ? .none : .default, value: viewStore.school) .padding(.horizontal, 16) .padding(.top, 24) .onChange(of: focusField) { newValue in @@ -124,14 +125,16 @@ public struct SchoolSettingView: View { ) ) .disabled(true) - .accessibilityLabel("학과 선택") - .accessibilityHint("학과를 선택하려면 두 번 탭하세요") } .padding(.bottom, 16) .onTapGesture { viewStore.send(.majorTextFieldDidTap, animation: .default) focusField = nil } + .accessibilityElement(children: .ignore) + .accessibilityLabel(viewStore.major.isEmpty ? "학과 선택" : "학과: \(viewStore.major)") + .accessibilityHint("탭하면 학과 선택 시트가 열립니다") + .accessibilityAddTraits(.isButton) } } @@ -205,21 +208,19 @@ public struct SchoolSettingView: View { ScrollView { LazyVStack(spacing: 16) { ForEach(viewStore.schoolList, id: \.schoolCode) { school in - HStack { - schoolRowView(school: school) - - Spacer() - } - .frame(maxWidth: .infinity) - .background { - Color.backgroundMain - } - .contentShape(.rect) - .onTapGesture { + Button { viewStore.send(.schoolRowDidSelect(school), animation: .default) focusField = .grade + } label: { + HStack { + schoolRowView(school: school) + + Spacer() + } + .frame(maxWidth: .infinity, minHeight: 44) + .contentShape(.rect) } - .accessibilityElement(children: .combine) + .buttonStyle(.plain) .accessibilityLabel("\(school.name) \(school.location)") .accessibilityHint("이 학교를 선택하려면 두 번 탭하세요") } diff --git a/Projects/Feature/SettingsFeature/Sources/SettingsView.swift b/Projects/Feature/SettingsFeature/Sources/SettingsView.swift index c70f11e4..5fbe139e 100644 --- a/Projects/Feature/SettingsFeature/Sources/SettingsView.swift +++ b/Projects/Feature/SettingsFeature/Sources/SettingsView.swift @@ -206,6 +206,21 @@ public struct SettingsView: View { .labelsHidden() .tint(.textPrimary) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + .accessibilityValue(isOn.wrappedValue ? "활성화됨" : "비활성화됨") + .accessibilityAddTraits(isToggleTraits) + .accessibilityAction { + isOn.wrappedValue.toggle() + } + } + + private var isToggleTraits: AccessibilityTraits { + if #available(iOS 17.0, *) { + return .isToggle + } else { + return .isButton + } } @ViewBuilder diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift index a8fc2a92..834a5a40 100644 --- a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift +++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift @@ -27,6 +27,7 @@ public struct WeeklyTimeTableCore: Reducer { public struct WeeklyTimeTable: Equatable { public let weekdays: [String] + public let fullWeekdays: [String] public let dates: [String] public let periods: [Int] public let subjects: [[String]] @@ -34,12 +35,14 @@ public struct WeeklyTimeTableCore: Reducer { public init( weekdays: [String], + fullWeekdays: [String], dates: [String], periods: [Int], subjects: [[String]], todayIndex: Int? = nil ) { self.weekdays = weekdays + self.fullWeekdays = fullWeekdays self.dates = dates self.periods = periods self.subjects = subjects @@ -203,6 +206,10 @@ public struct WeeklyTimeTableCore: Reducer { let weekdays = showWeekend ? ["월", "화", "수", "목", "금", "토", "일"] : ["월", "화", "수", "목", "금"] + let fullWeekdays = + showWeekend + ? ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] + : ["월요일", "화요일", "수요일", "목요일", "금요일"] var dates: [String] = [] let dateFormatter = DateFormatter() @@ -274,6 +281,7 @@ public struct WeeklyTimeTableCore: Reducer { return WeeklyTimeTable( weekdays: weekdays, + fullWeekdays: fullWeekdays, dates: dates, periods: periods, subjects: weeklySubjects, diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift index 8e091b7c..465c72c8 100644 --- a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift +++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift @@ -226,11 +226,17 @@ public struct WeeklyTimeTableView: View { .frame(width: firstColumnWidth, height: headerHeight) ForEach(0..: ViewModifier { var content: () -> T var height: CGFloat var backgroundColor: Color + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency var sheetDragging: some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { value in @@ -46,7 +48,7 @@ struct TWBottomSheet: ViewModifier { ZStack(alignment: .bottom) { if isShowing { - Color.lightBox + (reduceTransparency ? Color.black.opacity(0.6) : Color.lightBox) .ignoresSafeArea() .onTapGesture { withAnimation { @@ -55,6 +57,8 @@ struct TWBottomSheet: ViewModifier { } .gesture(sheetDragging) .transition(.opacity) + .accessibilityLabel("닫기") + .accessibilityAddTraits(.isButton) ZStack { backgroundColor @@ -72,6 +76,12 @@ struct TWBottomSheet: ViewModifier { } .fixedSize(horizontal: false, vertical: true) .transition(.move(edge: .bottom)) + .accessibilityAddTraits(.isModal) + .accessibilityAction(.escape) { + withAnimation { + isShowing = false + } + } .if(height != .infinity) { $0.frame(height: height) } @@ -80,7 +90,7 @@ struct TWBottomSheet: ViewModifier { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .ignoresSafeArea() } - .animation(.default, value: isShowing) + .animation(reduceMotion ? .none : .default, value: isShowing) } } diff --git a/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift b/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift index 95d01444..3f678430 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TWButton/TWButtonStyle.swift @@ -45,10 +45,16 @@ private extension TWButtonStyle { } var body: some View { - configuration.label + let contentView = configuration.label .twFont(.headline4, color: foreground) - .background(background) - .cornerRadius(8) + + if #available(iOS 26.0, watchOS 26.0, *) { + contentView + .glassEffect(.clear.tint(background).interactive(), in: .rect(cornerRadius: 8)) + } else { + contentView + .background(background, in: .rect(cornerRadius: 8)) + } } } @@ -74,9 +80,16 @@ private extension TWButtonStyle { } var body: some View { - configuration.label + let contentView = configuration.label .twFont(.headline4, color: foreground) - .background(background) + + if #available(iOS 26.0, watchOS 26.0, *) { + contentView + .glassEffect(.clear.tint(background).interactive(), in: .rect) + } else { + contentView + .background(background) + } } } } diff --git a/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift b/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift index d48f6461..2fc3249a 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift @@ -21,6 +21,7 @@ public extension View { .frame(width: 9, height: 16) .foregroundColor(Color.extraBlack) } + .accessibilityLabel("뒤로") } } .navigationBarBackButtonHidden(true) diff --git a/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift b/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift index 7b7a8f63..fe5febf4 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift @@ -7,7 +7,7 @@ public extension View { ) -> some View { self .font(.tw(style)) - .foregroundColor(color) + .foregroundStyle(color) } func twFont( diff --git a/Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift b/Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift index 55073f2e..607bf05f 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TWTextField/TWTextField.swift @@ -5,6 +5,7 @@ public struct TWTextField: View { private var placeholder: String private var onCommit: () -> Void @FocusState private var isFocused: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion public init( _ placeholder: String = "", @@ -27,10 +28,11 @@ public struct TWTextField: View { } .overlay { RoundedRectangle(cornerRadius: 8) - .stroke(isFocused ? Color.extraBlack : .clear, lineWidth: 1) + .strokeBorder(isFocused ? Color.extraBlack : .clear, lineWidth: 1) } .focused($isFocused) .onSubmit(onCommit) + .accessibilityLabel(placeholder) .zIndex(1) Group { @@ -40,6 +42,7 @@ public struct TWTextField: View { .offset(y: -44) .transition(.offset(y: 20)) .zIndex(0) + .accessibilityHidden(true) } else { Text(placeholder) .twFont(.body1, color: .unselectedPrimary) @@ -48,15 +51,16 @@ public struct TWTextField: View { isFocused = true } .zIndex(1) + .accessibilityHidden(true) } } - .animation(.default, value: isFocused) + .animation(reduceMotion ? .none : .default, value: isFocused) HStack { Spacer() Button { - withAnimation { + withAnimation(reduceMotion ? .none : nil) { text = "" } } label: { @@ -69,11 +73,13 @@ public struct TWTextField: View { EmptyView() } } + .accessibilityLabel("입력 내용 삭제") + .accessibilityHidden(!isFocused || text.isEmpty) } .zIndex(2) .padding(.trailing) - .animation(.default, value: text) - .animation(.default, value: isFocused) + .animation(reduceMotion ? .none : .default, value: text) + .animation(reduceMotion ? .none : .default, value: isFocused) } } } diff --git a/Projects/UserInterface/DesignSystem/Sources/TopTabbar/TopTabbar.swift b/Projects/UserInterface/DesignSystem/Sources/TopTabbar/TopTabbar.swift index 65ac1894..6ea55ac7 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TopTabbar/TopTabbar.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TopTabbar/TopTabbar.swift @@ -26,10 +26,11 @@ public struct TopTabbarView: View { currentTab = index } } label: { - VStack { + VStack(spacing: 0) { Text(itemTitle) .twFont(.headline4) .foregroundColor(tabForeground) + .frame(minHeight: 44) if isSelected { RoundedRectangle(cornerRadius: 17) @@ -41,6 +42,7 @@ public struct TopTabbarView: View { } } .frame(maxWidth: .infinity) + .contentShape(Rectangle()) .background { Color.backgroundMain }