From 74b06db9f103df8ff6009bf297437d59c2dc133b Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 30 May 2026 16:43:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94=20-=20#328?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Calendar/Core/TXCalendar+Layout.swift | 227 +++++++++++ .../Calendar/Core/TXCalendar+Paging.swift | 361 ++++++++++++++++++ .../Components/Calendar/Core/TXCalendar.swift | 332 +++++----------- .../Calendar/Utilities/TXCalendarUtil.swift | 44 +++ 4 files changed, 729 insertions(+), 235 deletions(-) create mode 100644 Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Layout.swift create mode 100644 Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Paging.swift diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Layout.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Layout.swift new file mode 100644 index 00000000..0e1bdced --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Layout.swift @@ -0,0 +1,227 @@ +// +// TXCalendar+Layout.swift +// SharedDesignSystem +// +// Created by Codex on 5/30/26. +// + +import SwiftUI + +// MARK: - Helpers +extension TXCalendar { + var headerSpacing: CGFloat { + switch mode { + case .weekly: config.weeklyHeaderSpacing + case .monthly: config.monthlyHeaderSpacing + } + } + + var horizontalPadding: CGFloat { + switch mode { + case .weekly: config.weeklyHorizontalPadding + case .monthly: config.monthlyHorizontalPadding + } + } + + var contentHeight: CGFloat { + let headerSectionHeight = config.weekdayHeight + headerSpacing + let verticalPadding: CGFloat = config.verticalPadding * 2 + + switch mode { + case .weekly: return headerSectionHeight + config.dateStyle.size + config.weeklyBottomPadding + verticalPadding + case .monthly: return headerSectionHeight + monthGridHeight + verticalPadding + } + } + + var monthGridHeight: CGFloat { + guard !weeks.isEmpty else { return 0 } + + let rowCount = max(weeks.count, config.monthlyPaging.minimumRowCount ?? 0) + let rowSpacing = config.monthlyRowSpacing * CGFloat(max(rowCount - 1, 0)) + return (config.dateStyle.size * CGFloat(rowCount)) + rowSpacing + } + + var monthlyPageHeight: CGFloat { + config.weekdayHeight + headerSpacing + monthGridHeight + } + + var isMonthlyVisualPagingEnabled: Bool { + mode == .monthly && config.monthlyPaging.isEnabled && currentDate != nil + } + + static var pagingAnimation: Animation { + .easeInOut(duration: 0.22) + } + + func weeklyPageSpacing(dayColumnSpacing: CGFloat) -> CGFloat { + dayColumnSpacing + } + + func weeklyPageDistance(pageWidth: CGFloat, dayColumnSpacing: CGFloat) -> CGFloat { + pageWidth + weeklyPageSpacing(dayColumnSpacing: dayColumnSpacing) + } + + func monthlyPageSpacing(dayColumnSpacing: CGFloat) -> CGFloat { + max(config.monthlyPaging.pageSpacing, dayColumnSpacing) + } + + func monthlyPageDate(monthOffset: Int) -> TXCalendarDate? { + guard var date = monthlyPagingBaseDate ?? currentDate?.wrappedValue else { + return nil + } + + guard monthOffset != 0 else { return date } + + if monthOffset > 0 { + for _ in 0.. [[TXCalendarDateItem]] { + guard let date = monthlyPageDate(monthOffset: monthOffset) else { + return weeks + } + + if monthOffset == 0, + monthlyPagingBaseDate == nil { + return weeks + } + + return config.monthlyPaging.pageWeeks?(date) + ?? TXCalendarDataGenerator.generateMonthData(for: date) + } + + func monthlyTargetDate(for swipe: SwipeGesture) -> TXCalendarDate? { + guard var date = monthlyPagingBaseDate ?? currentDate?.wrappedValue else { + return nil + } + + switch swipe { + case .previous: + date.goToPreviousMonth() + + case .next: + date.goToNextMonth() + } + return date + } + + var weekDateItems: [TXCalendarDateItem] { + weeks.first ?? [] + } + + var activeWeeklyReferenceDate: TXCalendarDate? { + weeklyPagingReferenceDate ?? weeklyReferenceDate + } + + func weeklyPageItems(weekOffset: Int) -> [TXCalendarDateItem] { + if let weeklyPagingReferenceDate { + guard weekOffset != 0 else { + return generatedWeeklyPageItems( + for: weeklyPagingReferenceDate, + weekOffset: 0 + ) + } + + guard let targetDate = weeklyTargetDate(for: weekOffset) else { + return weekDateItems + } + + return generatedWeeklyPageItems( + for: targetDate, + weekOffset: 0 + ) + } + + guard weekOffset != 0 else { + return weekDateItems + } + + guard let targetDate = weeklyTargetDate(for: weekOffset) else { + return weekDateItems + } + + return generatedWeeklyPageItems( + for: targetDate, + weekOffset: 0 + ) + } + + func generatedWeeklyPageItems( + for referenceDate: TXCalendarDate, + weekOffset: Int + ) -> [TXCalendarDateItem] { + TXCalendarDataGenerator.generateWeekData( + for: referenceDate, + weekOffset: weekOffset + ).first ?? [] + } + + func weeklyTargetDate(for swipe: SwipeGesture) -> TXCalendarDate? { + switch swipe { + case .previous: + return weeklyTargetDate(for: -1) + + case .next: + return weeklyTargetDate(for: 1) + } + } + + func weeklyTargetDate(for weekOffset: Int) -> TXCalendarDate? { + guard weekOffset != 0, + let referenceDate = activeWeeklyReferenceDate else { + return activeWeeklyReferenceDate + } + + return TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( + from: referenceDate, + by: weekOffset + ) + } + + var weeklyReferenceDate: TXCalendarDate? { + if let currentDate, currentDate.wrappedValue.day != nil { + return currentDate.wrappedValue + } + + let selectedItem = weekDateItems.first { item in + switch item.status { + case .selectedFilled, .selectedLine: + return item.dateComponents != nil + + case .completed, .default, .lastDate: + return false + } + } + + if let selectedItem, + let components = selectedItem.dateComponents { + return TXCalendarDate(components: components) + } + + guard let components = weekDateItems.compactMap(\.dateComponents).first else { + return nil + } + return TXCalendarDate(components: components) + } + + func weeklyHeaderTitle(index: Int, item: TXCalendarDateItem) -> String { + guard let components = item.dateComponents, + let year = components.year, + let month = components.month, + let day = components.day else { + return "" + } + let today = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: Date()) + let isToday = today.year == year && today.month == month && today.day == day + + return isToday ? "오늘" : weekdays[index] + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Paging.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Paging.swift new file mode 100644 index 00000000..cdb86497 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar+Paging.swift @@ -0,0 +1,361 @@ +// +// TXCalendar+Paging.swift +// SharedDesignSystem +// +// Created by Codex on 5/30/26. +// + +import SwiftUI + +// MARK: - Private Methods +extension TXCalendar { + func calendarSwipeGesture(pageWidth: CGFloat, dayColumnSpacing: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 16) + .onChanged { value in + handleSwipeChanged(value, pageWidth: pageWidth, dayColumnSpacing: dayColumnSpacing) + } + .onEnded { value in + handleSwipeEnded(value, pageWidth: pageWidth, dayColumnSpacing: dayColumnSpacing) + } + } + + func handleSwipeChanged( + _ value: DragGesture.Value, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + let horizontalDistance = value.translation.width + guard isHorizontalDrag(value.translation) else { + resetActiveDragTranslation() + return + } + + switch mode { + case .weekly: + updateWeeklyDragTranslation( + horizontalDistance, + pageDistance: weeklyPageDistance( + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + ) + + case .monthly: + updateMonthlyDragTranslation( + horizontalDistance, + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + } + } + + func handleSwipeEnded( + _ value: DragGesture.Value, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + let horizontalDistance = value.translation.width + guard isHorizontalDrag(value.translation) else { + resetActiveDragTranslation() + return + } + + let swipe: SwipeGesture = horizontalDistance > 0 ? .previous : .next + switch mode { + case .weekly: + finishWeeklySwipe( + swipe, + horizontalDistance: horizontalDistance, + pageDistance: weeklyPageDistance( + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + ) + + case .monthly: + finishMonthlySwipe( + swipe, + horizontalDistance: horizontalDistance, + pageWidth: pageWidth, + dayColumnSpacing: dayColumnSpacing + ) + } + } + + func isHorizontalDrag(_ translation: CGSize) -> Bool { + abs(translation.width) > abs(translation.height) + } + + func updateWeeklyDragTranslation(_ horizontalDistance: CGFloat, pageDistance: CGFloat) { + guard !isWeeklyPaging else { return } + + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = boundedWeeklyDragTranslation( + horizontalDistance, + pageDistance: pageDistance + ) + } + } + + func updateMonthlyDragTranslation( + _ horizontalDistance: CGFloat, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + guard isMonthlyVisualPagingEnabled, + !isMonthlyPaging else { + return + } + + let monthlyPageDistance = pageWidth + monthlyPageSpacing(dayColumnSpacing: dayColumnSpacing) + withTransaction(Transaction(animation: nil)) { + if monthlyPagingBaseDate == nil { + monthlyPagingBaseDate = currentDate?.wrappedValue + } + monthlyDragTranslation = boundedMonthlyDragTranslation( + horizontalDistance, + pageWidth: monthlyPageDistance + ) + } + } + + func finishWeeklySwipe( + _ swipe: SwipeGesture, + horizontalDistance: CGFloat, + pageDistance: CGFloat + ) { + handleWeeklySwipe( + swipe, + pageDistance: pageDistance, + releaseTranslation: boundedWeeklyDragTranslation( + horizontalDistance, + pageDistance: pageDistance + ) + ) + } + + func finishMonthlySwipe( + _ swipe: SwipeGesture, + horizontalDistance: CGFloat, + pageWidth: CGFloat, + dayColumnSpacing: CGFloat + ) { + guard isMonthlyVisualPagingEnabled else { + handleImmediateSwipe(swipe) + return + } + + let pageSpacing = monthlyPageSpacing(dayColumnSpacing: dayColumnSpacing) + let monthlyPageDistance = pageWidth + pageSpacing + handleMonthlySwipe( + swipe, + pageWidth: pageWidth, + pageSpacing: pageSpacing, + releaseTranslation: boundedMonthlyDragTranslation( + horizontalDistance, + pageWidth: monthlyPageDistance + ) + ) + } + + func handleWeeklySwipe( + _ swipe: SwipeGesture, + pageDistance: CGFloat, + releaseTranslation: CGFloat + ) { + guard canApplySwipe(swipe) else { + resetWeeklyPagingOffset() + return + } + + guard !isWeeklyPaging else { return } + + isWeeklyPaging = true + let targetDate = weeklyTargetDate(for: swipe) + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = 0 + weeklyPagingOffset = releaseTranslation + } + + withAnimation(Self.pagingAnimation) { + weeklyPagingOffset = pagingTargetOffset(for: swipe, pageDistance: pageDistance) + } completion: { + settleWeeklyPaging(to: targetDate) + applySwipe(swipe, animated: false) + } + } + + func handleMonthlySwipe( + _ swipe: SwipeGesture, + pageWidth: CGFloat, + pageSpacing: CGFloat, + releaseTranslation: CGFloat + ) { + guard canApplySwipe(swipe) else { + resetMonthlyPagingOffset() + return + } + + guard !isMonthlyPaging, + let baseDate = monthlyPagingBaseDate ?? currentDate?.wrappedValue, + let targetDate = monthlyTargetDate(for: swipe) else { + return + } + + isMonthlyPaging = true + withTransaction(Transaction(animation: nil)) { + monthlyPagingBaseDate = baseDate + monthlyDragTranslation = 0 + monthlyPagingOffset = releaseTranslation + } + + withAnimation(Self.pagingAnimation) { + monthlyPagingOffset = pagingTargetOffset(for: swipe, pageDistance: pageWidth + pageSpacing) + } completion: { + commitMonthlyVisualSwipe(swipe, targetDate: targetDate) + resetMonthlyPagingOffset() + } + } + + func commitMonthlyVisualSwipe(_ swipe: SwipeGesture, targetDate: TXCalendarDate) { + if let onSwipe { + onSwipe(swipe) + } else if let currentDate { + currentDate.wrappedValue = targetDate + } + } + + func handleImmediateSwipe(_ swipe: SwipeGesture) { + guard canApplySwipe(swipe) else { return } + applySwipe(swipe, animated: true) + } + + func canApplySwipe(_ swipe: SwipeGesture) -> Bool { + switch swipe { + case .previous: + return canMovePrevious + + case .next: + return canMoveNext + } + } + + func pagingTargetOffset(for swipe: SwipeGesture, pageDistance: CGFloat) -> CGFloat { + switch swipe { + case .previous: + return pageDistance + + case .next: + return -pageDistance + } + } + + func applySwipe(_ swipe: SwipeGesture, animated: Bool) { + if let onSwipe { + if animated { + withAnimation(Self.pagingAnimation) { + onSwipe(swipe) + } + } else { + onSwipe(swipe) + } + } else { + applySwipeToCurrentDate(swipe) + } + } + + func settleWeeklyPaging(to targetDate: TXCalendarDate?) { + withTransaction(Transaction(animation: nil)) { + weeklyPagingReferenceDate = targetDate + weeklyDragTranslation = 0 + weeklyPagingOffset = 0 + isWeeklyPaging = false + } + } + + func resetWeeklyPagingOffset() { + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = 0 + weeklyPagingOffset = 0 + isWeeklyPaging = false + } + } + + func clearStaleWeeklyPagingReferenceDate() { + guard let weeklyPagingReferenceDate, + let weeklyReferenceDate, + weeklyPagingReferenceDate != weeklyReferenceDate else { + return + } + + withTransaction(Transaction(animation: nil)) { + self.weeklyPagingReferenceDate = nil + } + } + + func resetMonthlyPagingOffset() { + withTransaction(Transaction(animation: nil)) { + monthlyDragTranslation = 0 + monthlyPagingOffset = 0 + monthlyPagingBaseDate = nil + isMonthlyPaging = false + } + } + + func resetActiveDragTranslation() { + withTransaction(Transaction(animation: nil)) { + weeklyDragTranslation = 0 + monthlyDragTranslation = 0 + if !isMonthlyPaging { + monthlyPagingBaseDate = nil + } + } + } + + func boundedWeeklyDragTranslation(_ translation: CGFloat, pageDistance: CGFloat) -> CGFloat { + if translation > 0, !canMovePrevious { + return 0 + } + if translation < 0, !canMoveNext { + return 0 + } + return min(max(translation, -pageDistance), pageDistance) + } + + func boundedMonthlyDragTranslation(_ translation: CGFloat, pageWidth: CGFloat) -> CGFloat { + boundedWeeklyDragTranslation(translation, pageDistance: pageWidth) + } + + func applySwipeToCurrentDate(_ swipe: SwipeGesture) { + guard let currentDate else { return } + + var updatedDate = currentDate.wrappedValue + switch mode { + case .weekly: + let offset: Int + switch swipe { + case .previous: + offset = -1 + + case .next: + offset = 1 + } + guard let date = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( + from: updatedDate, + by: offset + ) else { return } + updatedDate = date + + case .monthly: + switch swipe { + case .previous: + updatedDate.goToPreviousMonth() + + case .next: + updatedDate.goToNextMonth() + } + } + + currentDate.wrappedValue = updatedDate + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift index 50ecb32e..567139ac 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift @@ -33,6 +33,28 @@ public struct TXCalendar: View { /// 캘린더 레이아웃 설정입니다. public struct Configuration { + /// 월간 캘린더 페이징 설정입니다. + public struct MonthlyPagingConfiguration { + let isEnabled: Bool + let pageSpacing: CGFloat + let minimumRowCount: Int? + let pageWeeks: ((TXCalendarDate) -> [[TXCalendarDateItem]])? + + public static let disabled = Self() + + public init( + isEnabled: Bool = false, + pageSpacing: CGFloat = 0, + minimumRowCount: Int? = nil, + pageWeeks: ((TXCalendarDate) -> [[TXCalendarDateItem]])? = nil + ) { + self.isEnabled = isEnabled + self.pageSpacing = pageSpacing + self.minimumRowCount = minimumRowCount + self.pageWeeks = pageWeeks + } + } + let weeklyHorizontalPadding: CGFloat let monthlyHorizontalPadding: CGFloat let verticalPadding: CGFloat @@ -46,6 +68,7 @@ public struct TXCalendar: View { let backgroundColor: Color let dateStyle: TXCalendarDateStyle let dateCellBackground: ((TXCalendarDateItem) -> AnyView?)? + let monthlyPaging: MonthlyPagingConfiguration /// 캘린더 레이아웃 설정을 생성합니다. public init( @@ -61,7 +84,8 @@ public struct TXCalendar: View { weekdayColor: Color = Color.Gray.gray300, backgroundColor: Color = Color.Common.white, dateStyle: TXCalendarDateStyle = .init(), - dateCellBackground: ((TXCalendarDateItem) -> AnyView?)? = nil + dateCellBackground: ((TXCalendarDateItem) -> AnyView?)? = nil, + monthlyPaging: MonthlyPagingConfiguration = .disabled ) { self.weeklyHorizontalPadding = weeklyHorizontalPadding self.monthlyHorizontalPadding = monthlyHorizontalPadding @@ -76,23 +100,32 @@ public struct TXCalendar: View { self.backgroundColor = backgroundColor self.dateStyle = dateStyle self.dateCellBackground = dateCellBackground + self.monthlyPaging = monthlyPaging } } public static let defaultWeekdays = ["일", "월", "화", "수", "목", "금", "토"] - private let mode: DisplayMode - private let weekdays: [String] - private let weeks: [[TXCalendarDateItem]] - private let currentDate: Binding? - private let canMovePrevious: Bool - private let canMoveNext: Bool - private let config: Configuration - private let onSelect: (TXCalendarDateItem) -> Void - private let onSwipe: ((SwipeGesture) -> Void)? - @GestureState private var weeklyDragTranslation: CGFloat = 0 - @State private var weeklyPagingOffset: CGFloat = 0 - @State private var isWeeklyPaging = false + let mode: DisplayMode + let weekdays: [String] + let weeks: [[TXCalendarDateItem]] + let currentDate: Binding? + let canMovePrevious: Bool + let canMoveNext: Bool + let config: Configuration + let onSelect: (TXCalendarDateItem) -> Void + let onSwipe: ((SwipeGesture) -> Void)? + // Split TXCalendar paging helpers live in same module extension files. + // swiftlint:disable private_swiftui_state + @State var weeklyDragTranslation: CGFloat = 0 + @State var weeklyPagingOffset: CGFloat = 0 + @State var weeklyPagingReferenceDate: TXCalendarDate? + @State var isWeeklyPaging = false + @State var monthlyDragTranslation: CGFloat = 0 + @State var monthlyPagingOffset: CGFloat = 0 + @State var monthlyPagingBaseDate: TXCalendarDate? + @State var isMonthlyPaging = false + // swiftlint:enable private_swiftui_state /// 캘린더 컴포넌트를 생성합니다. public init( @@ -104,7 +137,6 @@ public struct TXCalendar: View { config: Configuration = .init(), onSelect: @escaping (TXCalendarDateItem) -> Void = { _ in }, onSwipe: ((SwipeGesture) -> Void)? = nil - ) { self.mode = mode self.weeks = weeks @@ -154,9 +186,23 @@ public struct TXCalendar: View { width: proxy.size.width, spacing: spacing ) - .highPriorityGesture(calendarSwipeGesture(pageWidth: pageWidth)) + .transaction { transaction in + if isWeeklyPaging || isMonthlyPaging { + transaction.disablesAnimations = false + transaction.animation = Self.pagingAnimation + } + } + .highPriorityGesture( + calendarSwipeGesture( + pageWidth: pageWidth, + dayColumnSpacing: spacing + ) + ) } .frame(height: contentHeight) + .onChange(of: weeks) { _, _ in + clearStaleWeeklyPagingReferenceDate() + } } } @@ -172,8 +218,15 @@ private extension TXCalendar { ) case .monthly: - monthlyWeekdayRow(spacing: spacing) - monthGrid(spacing: spacing) + if isMonthlyVisualPagingEnabled { + monthlyPageContent( + width: max(0, width - (horizontalPadding * 2)), + spacing: spacing + ) + } else { + monthlyWeekdayRow(spacing: spacing) + monthGrid(weeks: weeks, spacing: spacing) + } } } .padding(.vertical, config.verticalPadding) @@ -183,7 +236,8 @@ private extension TXCalendar { } func weeklyPageContent(width: CGFloat, spacing: CGFloat) -> some View { - HStack(spacing: 0) { + let pageSpacing = weeklyPageSpacing(dayColumnSpacing: spacing) + return HStack(spacing: pageSpacing) { weeklyPage(items: weeklyPageItems(weekOffset: -1), spacing: spacing) .frame(width: width) weeklyPage(items: weeklyPageItems(weekOffset: 0), spacing: spacing) @@ -191,7 +245,7 @@ private extension TXCalendar { weeklyPage(items: weeklyPageItems(weekOffset: 1), spacing: spacing) .frame(width: width) } - .offset(x: -width + weeklyPagingOffset + weeklyDragTranslation) + .offset(x: -(width + pageSpacing) + weeklyPagingOffset + weeklyDragTranslation) .frame( width: width, height: config.weekdayHeight + headerSpacing + config.dateStyle.size + config.weeklyBottomPadding, @@ -200,6 +254,29 @@ private extension TXCalendar { .clipped() } + func monthlyPageContent(width: CGFloat, spacing: CGFloat) -> some View { + let pageSpacing = monthlyPageSpacing(dayColumnSpacing: spacing) + return HStack(spacing: pageSpacing) { + monthlyPage(weeks: monthlyPageWeeks(monthOffset: -1), spacing: spacing) + .frame(width: width, height: monthlyPageHeight, alignment: .top) + monthlyPage(weeks: monthlyPageWeeks(monthOffset: 0), spacing: spacing) + .frame(width: width, height: monthlyPageHeight, alignment: .top) + monthlyPage(weeks: monthlyPageWeeks(monthOffset: 1), spacing: spacing) + .frame(width: width, height: monthlyPageHeight, alignment: .top) + } + .offset(x: -(width + pageSpacing) + monthlyPagingOffset + monthlyDragTranslation) + .frame(width: width, height: monthlyPageHeight, alignment: .leading) + .clipped() + } + + func monthlyPage(weeks: [[TXCalendarDateItem]], spacing: CGFloat) -> some View { + VStack(spacing: headerSpacing) { + monthlyWeekdayRow(spacing: spacing) + monthGrid(weeks: weeks, spacing: spacing) + } + .frame(height: monthlyPageHeight, alignment: .top) + } + func weeklyPage(items: [TXCalendarDateItem], spacing: CGFloat) -> some View { VStack(spacing: headerSpacing) { weekdayRow(items: items, spacing: spacing) @@ -237,7 +314,7 @@ private extension TXCalendar { } } - func monthGrid(spacing: CGFloat) -> some View { + func monthGrid(weeks: [[TXCalendarDateItem]], spacing: CGFloat) -> some View { Grid( horizontalSpacing: spacing, verticalSpacing: config.monthlyRowSpacing @@ -267,218 +344,3 @@ private extension TXCalendar { .buttonStyle(.plain) } } - -// MARK: - Helpers -private extension TXCalendar { - var headerSpacing: CGFloat { - switch mode { - case .weekly: config.weeklyHeaderSpacing - case .monthly: config.monthlyHeaderSpacing - } - } - - var horizontalPadding: CGFloat { - switch mode { - case .weekly: config.weeklyHorizontalPadding - case .monthly: config.monthlyHorizontalPadding - } - } - - var contentHeight: CGFloat { - let headerSectionHeight = config.weekdayHeight + headerSpacing - let verticalPadding: CGFloat = config.verticalPadding * 2 - - switch mode { - case .weekly: return headerSectionHeight + config.dateStyle.size + config.weeklyBottomPadding + verticalPadding - case .monthly: return headerSectionHeight + monthGridHeight + verticalPadding - } - } - - var monthGridHeight: CGFloat { - guard !weeks.isEmpty else { return 0 } - - let rowCount = CGFloat(weeks.count) - let rowSpacing = config.monthlyRowSpacing * CGFloat(weeks.count - 1) - return (config.dateStyle.size * rowCount) + rowSpacing - } - - var weekDateItems: [TXCalendarDateItem] { - weeks.first ?? [] - } - - var weeklyReferenceDate: TXCalendarDate? { - if let currentDate, currentDate.wrappedValue.day != nil { - return currentDate.wrappedValue - } - - let selectedItem = weekDateItems.first { item in - switch item.status { - case .selectedFilled, .selectedLine: - return item.dateComponents != nil - case .completed, .default, .lastDate: - return false - } - } - - if let selectedItem, - let components = selectedItem.dateComponents { - return TXCalendarDate(components: components) - } - - guard let components = weekDateItems.compactMap(\.dateComponents).first else { - return nil - } - return TXCalendarDate(components: components) - } - -} - -// MARK: - Private Methods -private extension TXCalendar { - func calendarSwipeGesture(pageWidth: CGFloat) -> some Gesture { - DragGesture(minimumDistance: 16) - .updating($weeklyDragTranslation) { value, state, _ in - guard mode == .weekly else { return } - - let horizontalDistance = value.translation.width - let verticalDistance = value.translation.height - guard abs(horizontalDistance) > abs(verticalDistance) else { return } - - state = boundedWeeklyDragTranslation(horizontalDistance, pageWidth: pageWidth) - } - .onEnded { value in - let horizontalDistance = value.translation.width - let verticalDistance = value.translation.height - guard abs(horizontalDistance) > abs(verticalDistance) else { return } - - let swipe: SwipeGesture = horizontalDistance > 0 ? .previous : .next - handleSwipe(swipe, pageWidth: pageWidth) - } - } - - func handleSwipe(_ swipe: SwipeGesture, pageWidth: CGFloat) { - switch swipe { - case .previous: - guard canMovePrevious else { - resetWeeklyPagingOffset() - return - } - case .next: - guard canMoveNext else { - resetWeeklyPagingOffset() - return - } - } - - guard mode == .weekly else { - applySwipe(swipe) - return - } - guard !isWeeklyPaging else { return } - - let targetOffset: CGFloat - switch swipe { - case .previous: targetOffset = pageWidth - case .next: targetOffset = -pageWidth - } - isWeeklyPaging = true - withAnimation(.easeInOut(duration: 0.22)) { - weeklyPagingOffset = targetOffset - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { - applySwipe(swipe) - resetWeeklyPagingOffset() - } - } - - func applySwipe(_ swipe: SwipeGesture) { - if let onSwipe { - withAnimation(.easeInOut(duration: 0.2)) { - onSwipe(swipe) - } - } else { - applySwipeToCurrentDate(swipe) - } - } - - func resetWeeklyPagingOffset() { - withTransaction(Transaction(animation: nil)) { - weeklyPagingOffset = 0 - isWeeklyPaging = false - } - } - - func boundedWeeklyDragTranslation(_ translation: CGFloat, pageWidth: CGFloat) -> CGFloat { - if translation > 0, !canMovePrevious { - return 0 - } - if translation < 0, !canMoveNext { - return 0 - } - return min(max(translation, -pageWidth), pageWidth) - } - - func weeklyPageItems(weekOffset: Int) -> [TXCalendarDateItem] { - guard weekOffset != 0 else { - return weekDateItems - } - guard let referenceDate = weeklyReferenceDate else { - return weekDateItems - } - let items = TXCalendarDataGenerator.generateWeekData( - for: referenceDate, - weekOffset: weekOffset - ).first ?? [] - return items.map { item in - switch item.status { - case .selectedLine, .selectedFilled: - return TXCalendarDateItem( - id: item.id, - text: item.text, - status: .default, - dateComponents: item.dateComponents - ) - case .completed, .default, .lastDate: - return item - } - } - } - - func applySwipeToCurrentDate(_ swipe: SwipeGesture) { - guard let currentDate else { return } - - var updatedDate = currentDate.wrappedValue - switch mode { - case .weekly: - let offset: Int - switch swipe { - case .previous: offset = -1 - case .next: offset = 1 - } - guard let date = TXCalendarUtil.dateByAddingWeek(from: updatedDate, by: offset) else { return } - updatedDate = date - - case .monthly: - switch swipe { - case .previous: updatedDate.goToPreviousMonth() - case .next: updatedDate.goToNextMonth() - } - } - - currentDate.wrappedValue = updatedDate - } - - func weeklyHeaderTitle(index: Int, item: TXCalendarDateItem) -> String { - guard let components = item.dateComponents, - let year = components.year, - let month = components.month, - let day = components.day else { - return "" - } - let today = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: Date()) - let isToday = today.year == year && today.month == month && today.day == day - - return isToday ? "오늘" : weekdays[index] - } -} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift index d64fc272..7239a04b 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Utilities/TXCalendarUtil.swift @@ -65,4 +65,48 @@ public enum TXCalendarUtil { } return TXCalendarDate(year: year, month: month, day: day) } + + /// 주간 캘린더 스와이프 시 경계 요일을 보정한 날짜를 반환합니다. + /// + /// ## 사용 예시 + /// ```swift + /// let sunday = TXCalendarDate(year: 2026, month: 2, day: 8) + /// let previous = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe(from: sunday, by: -1) + /// ``` + /// + /// 일요일에서 이전으로 이동하면 전날인 토요일로, 토요일에서 다음으로 이동하면 다음 날인 일요일로 이동합니다. + /// 그 외 날짜는 기존 주 단위 이동과 동일하게 처리합니다. + public static func dateByApplyingWeeklyBoundarySwipe( + from date: TXCalendarDate, + by offset: Int + ) -> TXCalendarDate? { + guard let baseDate = date.date else { return nil } + + let calendar = Calendar(identifier: .gregorian) + let weekday = calendar.component(.weekday, from: baseDate) + let dayOffset: Int + + switch (weekday, offset) { + case (1, let offset) where offset < 0: + dayOffset = -1 + + case (7, let offset) where offset > 0: + dayOffset = 1 + + default: + return dateByAddingWeek(from: date, by: offset) + } + + guard let targetDate = calendar.date(byAdding: .day, value: dayOffset, to: baseDate) else { + return nil + } + + let components = calendar.dateComponents([.year, .month, .day], from: targetDate) + guard let year = components.year, + let month = components.month, + let day = components.day else { + return nil + } + return TXCalendarDate(year: year, month: month, day: day) + } } From 72e9a5d6c2351031a40cb42556b201602548f046 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 30 May 2026 16:43:36 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=ED=99=88=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A0=84=ED=99=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20-=20#328?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Sources/Goal/EditGoalListReducer+Impl.swift | 4 ++-- .../Feature/Home/Sources/Home/HomeCalendarSection.swift | 6 +++--- Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift index 0d2951ba..354e625e 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift @@ -50,7 +50,7 @@ extension EditGoalListReducer { case let .view(.weekCalendarSwipe(swipe)): switch swipe { case .next: - guard let nextWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let nextWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: 1 ) else { @@ -59,7 +59,7 @@ extension EditGoalListReducer { return .send(.internal(.setCalendarDate(nextWeekDate))) case .previous: - guard let previousWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let previousWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: -1 ) else { diff --git a/Projects/Feature/Home/Sources/Home/HomeCalendarSection.swift b/Projects/Feature/Home/Sources/Home/HomeCalendarSection.swift index 9bc11de7..e4e7ef9c 100644 --- a/Projects/Feature/Home/Sources/Home/HomeCalendarSection.swift +++ b/Projects/Feature/Home/Sources/Home/HomeCalendarSection.swift @@ -32,9 +32,6 @@ struct HomeCalendarSection: View { ) .frame(maxWidth: .infinity, maxHeight: 76) .perfControl(slug: "home", element: "calendar") - .transaction { transaction in - transaction.animation = nil - } if UITestMode.isProbeScenario { calendarView.perfStateMarker( @@ -42,6 +39,9 @@ struct HomeCalendarSection: View { key: "calendar-month", value: "\(store.calendarDate.year)-\(store.calendarDate.month)" ) + .transaction { transaction in + transaction.animation = nil + } } else { calendarView } diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 14df2ed1..056480b0 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -303,7 +303,7 @@ extension HomeReducer { case let .view(.weekCalendarSwipe(swipe)): switch swipe { case .next: - guard let nextWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let nextWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: 1 ) else { @@ -312,7 +312,7 @@ extension HomeReducer { return .send(.internal(.setCalendarDate(nextWeekDate))) case .previous: - guard let previousWeekDate = TXCalendarUtil.dateByAddingWeek( + guard let previousWeekDate = TXCalendarUtil.dateByApplyingWeeklyBoundarySwipe( from: state.calendarDate, by: -1 ) else { From 993f38885b6fe5b69fa377b2b2a4c2195a093e51 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 30 May 2026 16:43:42 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20#328?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BottomSheet/CalendarSheetModifier.swift | 9 +++- .../BottomSheet/TXCalendarBottomSheet.swift | 42 +++++++++++++++---- .../Modifiers/View+TxBottomSheet.swift | 25 +++++++---- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift index 7027e14b..e9ad2060 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/CalendarSheetModifier.swift @@ -23,7 +23,7 @@ enum CalendarSheetConstants { static let dragVelocityThreshold: CGFloat = 500 static let springResponse: Double = 0.35 static let springDamping: Double = 0.86 - static let hiddenOffsetFallback: CGFloat = 1000 + static let hiddenOffsetFallback: CGFloat = 1_000 } // MARK: - Calendar Sheet Modifier @@ -94,7 +94,11 @@ struct CalendarSheetModifier: ViewModifier { .padding(.bottom, safeAreaBottom) .background(Color.Common.white) .clipShape(.rect(cornerRadii: topCornerRadii)) - .transaction { $0.animation = nil } + .transaction { transaction in + if dragOffset != 0 { + transaction.animation = nil + } + } } @ViewBuilder @@ -107,6 +111,7 @@ struct CalendarSheetModifier: ViewModifier { onComplete: onComplete, isDateEnabled: isDateEnabled ) + case let .custom(content): TXCalendarBottomSheet( selectedDate: $selectedDate, diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift index 0975baee..01637b05 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift @@ -183,15 +183,34 @@ public struct DefaultCalendarButton: View { // MARK: - Private Views private extension TXCalendarBottomSheet { + static var minimumMonthlyRowCount: Int { 6 } + static var calendarConfig: TXCalendar.Configuration { .init( monthlyHeaderSpacing: Spacing.spacing7, - monthlyRowSpacing: Spacing.spacing6 + monthlyRowSpacing: Spacing.spacing6, + monthlyPaging: .init(minimumRowCount: minimumMonthlyRowCount) ) } var calendarConfig: TXCalendar.Configuration { - Self.calendarConfig + let isDateEnabled = isDateEnabled + return .init( + monthlyHeaderSpacing: Spacing.spacing7, + monthlyRowSpacing: Spacing.spacing6, + monthlyPaging: .init( + isEnabled: true, + pageSpacing: Spacing.spacing7, + minimumRowCount: Self.minimumMonthlyRowCount, + pageWeeks: { date in + let weeks = Self.makeCalendarData(for: date).weeks + return Self.applyDisabledStatus( + to: weeks, + isDateEnabled: isDateEnabled + ) + } + ) + ) } static func makeCalendarData(for date: TXCalendarDate) -> CalendarPresentationData { @@ -211,9 +230,9 @@ private extension TXCalendarBottomSheet { guard !weeks.isEmpty else { return headerSectionHeight + verticalPadding } - let rowCount = CGFloat(weeks.count) - let rowSpacing = config.monthlyRowSpacing * CGFloat(weeks.count - 1) - let monthGridHeight = (config.dateStyle.size * rowCount) + rowSpacing + let rowCount = max(weeks.count, Self.minimumMonthlyRowCount) + let rowSpacing = config.monthlyRowSpacing * CGFloat(max(rowCount - 1, 0)) + let monthGridHeight = (config.dateStyle.size * CGFloat(rowCount)) + rowSpacing return headerSectionHeight + monthGridHeight + verticalPadding } @@ -244,7 +263,7 @@ private extension TXCalendarBottomSheet { func datePickerView(height: CGFloat) -> some View { HStack(spacing: 0) { Picker("Year", selection: selectedYear) { - ForEach(1940...2099, id: \.self) { year in + ForEach(1_940...2_099, id: \.self) { year in Text(verbatim: "\(year)년").tag(year) } } @@ -261,7 +280,6 @@ private extension TXCalendarBottomSheet { .padding(.horizontal, Spacing.spacing7) } - var selectedYear: Binding { Binding( get: { selectedDate.year }, @@ -297,6 +315,16 @@ private extension TXCalendarBottomSheet { } func applyDisabledStatus(to weeks: [[TXCalendarDateItem]]) -> [[TXCalendarDateItem]] { + Self.applyDisabledStatus( + to: weeks, + isDateEnabled: isDateEnabled + ) + } + + static func applyDisabledStatus( + to weeks: [[TXCalendarDateItem]], + isDateEnabled: ((TXCalendarDateItem) -> Bool)? + ) -> [[TXCalendarDateItem]] { guard let isDateEnabled else { return weeks } return weeks.map { week in week.map { item in diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift index 33150755..59df02de 100644 --- a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxBottomSheet.swift @@ -74,22 +74,31 @@ private struct TXBottomSheetModifier: ViewModifier { isPresented: $isCoverPresented, onDismiss: { resetSheetState() + }, + content: { + ZStack(alignment: .bottom) { + sheetView + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea(.container, edges: .bottom) + .onAppear { presentAnimated() } + .presentationBackground { dimmedBackground } } - ) { - ZStack(alignment: .bottom) { - sheetView + ) + .transaction { transaction in + if shouldDisableCoverTransactionAnimation { + transaction.disablesAnimations = true } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .ignoresSafeArea(.container, edges: .bottom) - .onAppear { presentAnimated() } - .presentationBackground { dimmedBackground } } - .transaction { $0.disablesAnimations = true } } } // MARK: - SubViews { private extension TXBottomSheetModifier { + var shouldDisableCoverTransactionAnimation: Bool { + isDismissing || isCoverPresented != isPresented + } + var sheetView: some View { VStack(spacing: 0) { dragContainer