import SwiftUI private enum AuthContentSettingsLayout { static let topInset: CGFloat = 26 static let headerHeight: CGFloat = 54 static let touchHeight: CGFloat = 44 static let buttonHeight: CGFloat = 52 static let titleLineHeight: CGFloat = 23 static let captionLineHeight: CGFloat = 17 static let radioSize: CGFloat = 22 static let radioDotSize: CGFloat = 12 static let radioRowGap: CGFloat = 1 static let radioBottomInset: CGFloat = 10 static let switchWidth: CGFloat = 44 static let switchHeight: CGFloat = 24 static let switchInset: CGFloat = 2 static let strokeWidth: CGFloat = 1 } struct AuthContentSettingsSheet: View { @Binding var isAdultContentVisible: Bool @Binding var contentType: ContentType let onConfirm: () -> Void var body: some View { VStack(spacing: 0) { ViewThatFits(in: .vertical) { settingsContent .fixedSize(horizontal: false, vertical: true) ScrollView(.vertical, showsIndicators: false) { settingsContent } .scrollBounceBehavior(.basedOnSize) } Button(action: onConfirm) { Text(I18n.Settings.Content.confirmAuthSettings) .appFont(size: SodaTypography.heading4.size, weight: .medium) .foregroundColor(.white) .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.buttonHeight) .background(Color.soda400) .clipShape(Capsule()) } .buttonStyle(.plain) .accessibilityIdentifier("authContentSettings.confirm") .padding(.horizontal, SodaSpacing.s14) .padding(.vertical, SodaSpacing.s8) .fixedSize(horizontal: false, vertical: true) } .background(Color.gray900) } private var settingsContent: some View { VStack(spacing: 0) { Text(I18n.Settings.contentViewSettings) .appFont(size: SodaTypography.heading4.size, weight: .medium) .foregroundColor(.white) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.headerHeight) .accessibilityAddTraits(.isHeader) sensitiveContentRow .padding(.top, SodaSpacing.s8) .padding(.bottom, isAdultContentVisible ? SodaSpacing.s14 : SodaSpacing.s8) if isAdultContentVisible { preferenceSection } HStack(alignment: .top, spacing: SodaSpacing.s6) { Image(systemName: "info.circle") .resizable() .frame(width: SodaSpacing.s16, height: SodaSpacing.s16) .accessibilityHidden(true) Text(I18n.Settings.Content.authSettingsGuide) .appFont(.caption3) .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.captionLineHeight, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } .foregroundColor(.gray400) .padding(.top, isAdultContentVisible ? 0 : SodaSpacing.s8) .padding(.bottom, SodaSpacing.s20) } .padding(.top, AuthContentSettingsLayout.topInset) .padding(.horizontal, SodaSpacing.s14) } private var sensitiveContentRow: some View { HStack(spacing: SodaSpacing.s12) { VStack(alignment: .leading, spacing: SodaSpacing.s4) { Text(I18n.Settings.Content.sensitiveContentTitle) .appFont(.body2) .fontWeight(.semibold) .foregroundColor(.white) .frame(minHeight: AuthContentSettingsLayout.titleLineHeight, alignment: .leading) Text(isAdultContentVisible ? I18n.Settings.Content.authSensitiveContentOnDescription : I18n.Settings.Content.authSensitiveContentOffDescription) .appFont(.caption3) .foregroundColor(.gray400) .frame(minHeight: AuthContentSettingsLayout.captionLineHeight, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) Toggle(I18n.Settings.Content.sensitiveContentTitle, isOn: $isAdultContentVisible) .toggleStyle(AuthContentSettingsToggleStyle()) .accessibilityIdentifier("authContentSettings.toggle") } } private var preferenceSection: some View { VStack(alignment: .leading, spacing: 0) { Rectangle() .fill(Color.gray800) .frame(height: AuthContentSettingsLayout.strokeWidth) Text(I18n.Settings.Content.preferredContent) .appFont(.caption2) .foregroundColor(.gray400) .frame(minHeight: AuthContentSettingsLayout.captionLineHeight) .padding(.top, SodaSpacing.s14) HStack(spacing: SodaSpacing.s12) { preferenceButton(.ALL, title: I18n.Settings.Content.all) preferenceButton(.MALE, title: I18n.Settings.Content.maleOriented) preferenceButton(.FEMALE, title: I18n.Settings.Content.femaleOriented) } .padding(.top, AuthContentSettingsLayout.radioRowGap) .padding(.bottom, AuthContentSettingsLayout.radioBottomInset) } } private func preferenceButton(_ type: ContentType, title: String) -> some View { Button { contentType = type } label: { HStack(spacing: SodaSpacing.s6) { Circle() .stroke(Color.gray700, lineWidth: AuthContentSettingsLayout.strokeWidth) .frame(width: AuthContentSettingsLayout.radioSize, height: AuthContentSettingsLayout.radioSize) .overlay { if contentType == type { Circle() .fill(Color.soda400) .frame(width: AuthContentSettingsLayout.radioDotSize, height: AuthContentSettingsLayout.radioDotSize) } } Text(title) .appFont(.body2) .foregroundColor(.white) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.touchHeight, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(title) .accessibilityValue(contentType == type ? I18n.Settings.Content.selectedPreference : I18n.Settings.Content.unselectedPreference) .accessibilityAddTraits(contentType == type ? .isSelected : []) .accessibilityIdentifier("authContentSettings.preference.\(type.rawValue)") } } private struct AuthContentSettingsToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { Button { configuration.isOn.toggle() } label: { Capsule() .fill(configuration.isOn ? Color.soda400 : Color.gray500) .frame(width: AuthContentSettingsLayout.switchWidth, height: AuthContentSettingsLayout.switchHeight) .overlay(alignment: configuration.isOn ? .trailing : .leading) { Circle() .fill(Color.white) .frame(width: SodaSpacing.s20, height: SodaSpacing.s20) .padding(AuthContentSettingsLayout.switchInset) } .frame(minHeight: AuthContentSettingsLayout.touchHeight) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityRepresentation { Toggle(isOn: configuration.$isOn) { configuration.label } .toggleStyle(.switch) } } } private struct AuthContentSettingsModifier: ViewModifier { @ObservedObject var viewModel: MyPageViewModel @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isHostPresented = false @State private var panelProgress: CGFloat = 0 private var callerErrorIsPresented: Binding { Binding( get: { viewModel.isShowPopup && !viewModel.isShowAuthContentSettings }, set: { viewModel.isShowPopup = $0 } ) } func body(content: Content) -> some View { content .disabled(viewModel.isSubmittingAuth) .overlay { if viewModel.isSubmittingAuth { LoadingView() } } .sodaToast(isPresented: callerErrorIsPresented, message: viewModel.errorMessage, autohideIn: 2) .fullScreenCover(isPresented: $isHostPresented, onDismiss: { panelProgress = 0 viewModel.submitPendingAuthVerification() }) { GeometryReader { proxy in ZStack(alignment: .bottom) { Color.black.opacity(0.6) .ignoresSafeArea() AuthContentSettingsSheet( isAdultContentVisible: $viewModel.authIsAdultContentVisible, contentType: $viewModel.authContentType, onConfirm: viewModel.confirmAuthContentSettings ) .padding(.leading, proxy.safeAreaInsets.leading) .padding(.trailing, proxy.safeAreaInsets.trailing) .padding(.bottom, proxy.safeAreaInsets.bottom) .background(Color.gray900) .clipShape(RoundedCorner(radius: SodaRadius.r14, corners: [.topLeft, .topRight])) .accessibilityElement(children: .contain) .accessibilityIdentifier("authContentSettings.panel") .disabled(viewModel.isSubmittingAuth) .overlay { if viewModel.isSubmittingAuth { LoadingView() } } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) .modifier(AuthContentSettingsPanelMotion( progress: panelProgress, reduceMotion: reduceMotion, onHidden: closeHostIfHidden )) } .ignoresSafeArea(.container, edges: [.horizontal, .bottom]) } .interactiveDismissDisabled(true) .presentationBackground(.clear) .onAppear { DispatchQueue.main.async { updatePanel() } } } .onAppear { if viewModel.isShowAuthContentSettings { setHostPresented(true) } } .onChange(of: viewModel.isShowAuthContentSettings) { isPresented in if isPresented && !isHostPresented { setHostPresented(true) } else if isHostPresented { updatePanel() } } .onChange(of: reduceMotion) { _ in if isHostPresented { updatePanel() } } } private func setHostPresented(_ isPresented: Bool) { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { isHostPresented = isPresented } } private func updatePanel() { let progress: CGFloat = viewModel.isShowAuthContentSettings ? 1 : 0 if reduceMotion || panelProgress == progress { panelProgress = progress closeHostIfHidden() } else { withAnimation(.easeInOut(duration: 0.25)) { panelProgress = progress } } } private func closeHostIfHidden() { guard isHostPresented, panelProgress == 0, !viewModel.isShowAuthContentSettings else { return } setHostPresented(false) } } private struct AuthContentSettingsPanelMotion: GeometryEffect { var progress: CGFloat let reduceMotion: Bool let onHidden: () -> Void var animatableData: CGFloat { get { progress } set { progress = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { if progress == 0 { DispatchQueue.main.async(execute: onHidden) } return ProjectionTransform(CGAffineTransform(translationX: 0, y: reduceMotion ? 0 : size.height * (1 - progress))) } } extension View { func authContentSettings(viewModel: MyPageViewModel) -> some View { modifier(AuthContentSettingsModifier(viewModel: viewModel)) } } struct AuthContentSettingsSheet_Previews: PreviewProvider { static var previews: some View { Group { AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {}) .frame(width: 402) .previewDisplayName("ON / ALL") AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {}) .frame(width: 402) .previewDisplayName("OFF / ALL") } .padding(.bottom, 34) .background(Color.gray900) .clipShape(RoundedCorner(radius: SodaRadius.r14, corners: [.topLeft, .topRight])) .environment(\.locale, Locale(identifier: "ko")) .preferredColorScheme(.dark) .previewLayout(.sizeThatFits) } }