From fe4aa7462f9c00ce4fd12a282f49f3f0c09ad167 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 3 Mar 2026 19:45:37 -0800 Subject: [PATCH 1/2] fix(calendar): header sizing and selecteddate bindings https://github.com/nstudio/nativescript-ui-kit/issues/46 --- packages/nativescript-calendar/common.ts | 27 +++++++++++++++++++ packages/nativescript-calendar/index.ios.ts | 4 +++ .../platforms/ios/src/NCalendarView.swift | 17 +++++++++--- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/nativescript-calendar/common.ts b/packages/nativescript-calendar/common.ts index a95c65a..4049d29 100644 --- a/packages/nativescript-calendar/common.ts +++ b/packages/nativescript-calendar/common.ts @@ -484,12 +484,39 @@ firstDayOfWeekProperty.register(NCalendarCommon); export const selectedDatesProperty = new Property({ name: 'selectedDates', defaultValue: [], + valueChanged: (target, _oldValue, newValue) => { + if (target._internalSelectionChange) return; + target._selectedKeys.clear(); + if (newValue && newValue.length) { + for (const d of newValue) { + target._selectedKeys.add(target._toDateKey(d)); + } + } + }, }); selectedDatesProperty.register(NCalendarCommon); export const selectedDateRangeProperty = new Property({ name: 'selectedDateRange', defaultValue: undefined, + valueChanged: (target, _oldValue, newValue) => { + if (target._internalSelectionChange) return; + if (newValue && newValue.start && newValue.end) { + target._rangeStart = newValue.start; + target._rangeEnd = newValue.end; + target._selectedKeys.clear(); + const cursor = new Date(newValue.start.getTime()); + const endTime = newValue.end.getTime(); + while (cursor.getTime() <= endTime) { + target._selectedKeys.add(target._toDateKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + } else { + target._rangeStart = null; + target._rangeEnd = null; + target._selectedKeys.clear(); + } + }, }); selectedDateRangeProperty.register(NCalendarCommon); diff --git a/packages/nativescript-calendar/index.ios.ts b/packages/nativescript-calendar/index.ios.ts index f1ca622..1b751fb 100644 --- a/packages/nativescript-calendar/index.ios.ts +++ b/packages/nativescript-calendar/index.ios.ts @@ -248,6 +248,10 @@ export class NCalendar extends NCalendarCommon { [firstDayOfWeekProperty.setNative](value: number) { if (this._calView) { this._calView.firstDayOfWeekJS = value; + // After recreating the CalendarView, restore scroll position + if (this._currentMonth) { + this.scrollToMonth(this._currentMonth.year, this._currentMonth.month, false); + } } } diff --git a/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift b/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift index 1286d10..e79b0c3 100644 --- a/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift +++ b/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift @@ -68,8 +68,8 @@ public class NCalendarView: UIView { horizontalMonthLabel?.frame = CGRect(x: 0, y: 0, width: bounds.width, height: labelHeight) let dayWidth = (bounds.width - 6 * horizontalDayMarginPt) / 7 - let dowRowHeight: CGFloat = ceil(dayOfWeekFontSizePt * 1.5) + 8 - let maxMonthHeight = dowRowHeight + 6 * dayWidth + 5 * verticalDayMarginPt + let dowRowHeight: CGFloat = ceil(dayOfWeekFontSizePt * 1.8) + 10 + let maxMonthHeight = dowRowHeight + 6 * dayWidth + 6 * verticalDayMarginPt calendarView.frame = CGRect(x: 0, y: labelHeight, width: bounds.width, height: maxMonthHeight) } else { horizontalMonthLabel?.isHidden = true @@ -124,7 +124,18 @@ public class NCalendarView: UIView { @objc public var minDateMs: Double = 0 { didSet { rebuildContent() } } @objc public var maxDateMs: Double = 0 { didSet { rebuildContent() } } - @objc public var firstDayOfWeekJS: Int = 0 { didSet { updateCalendar(); rebuildContent() } } + @objc public var firstDayOfWeekJS: Int = 0 { + didSet { + guard oldValue != firstDayOfWeekJS else { return } + updateCalendar() + if displayModeStr == "week" { + rebuildContent() + } else { + recreateCalendarView() + setNeedsLayout() + } + } + } @objc public var interMonthSpacingPt: CGFloat = 0 { didSet { rebuildContent() } } @objc public var verticalDayMarginPt: CGFloat = 0 { didSet { rebuildContent() } } From 0acad8c6a063e17897ce35533ffac2aa93bacc2c Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 5 Mar 2026 18:25:24 -0800 Subject: [PATCH 2/2] fix: more robust selection and programmatic date handling --- packages/nativescript-calendar/common.ts | 16 +++- .../nativescript-calendar/index.android.ts | 54 +++++++++++- packages/nativescript-calendar/index.ios.ts | 45 +++++++++- .../platforms/ios/src/NCalendarView.swift | 88 +++++++++++++++++-- 4 files changed, 188 insertions(+), 15 deletions(-) diff --git a/packages/nativescript-calendar/common.ts b/packages/nativescript-calendar/common.ts index 4049d29..09b17a2 100644 --- a/packages/nativescript-calendar/common.ts +++ b/packages/nativescript-calendar/common.ts @@ -361,8 +361,17 @@ export abstract class NCalendarCommon extends View { } selectDateRange(start: Date, end: Date): void { - this._rangeStart = normalizeDate(start); - this._rangeEnd = normalizeDate(end); + const normalizedStart = normalizeDate(start); + const normalizedEnd = normalizeDate(end); + + if (normalizedStart.getTime() <= normalizedEnd.getTime()) { + this._rangeStart = normalizedStart; + this._rangeEnd = normalizedEnd; + } else { + this._rangeStart = normalizedEnd; + this._rangeEnd = normalizedStart; + } + this._selectedKeys.clear(); const cursor = new Date(this._rangeStart.getTime()); while (cursor.getTime() <= this._rangeEnd.getTime()) { @@ -371,6 +380,9 @@ export abstract class NCalendarCommon extends View { } this._syncSelectedDateRange(); this._refreshAfterSelectionChange(); + + // Keep programmatic range selection behavior intuitive by navigating to the range start. + this.scrollToDate(this._rangeStart, false); } clearSelection(): void { diff --git a/packages/nativescript-calendar/index.android.ts b/packages/nativescript-calendar/index.android.ts index f2c9b4b..71164b9 100644 --- a/packages/nativescript-calendar/index.android.ts +++ b/packages/nativescript-calendar/index.android.ts @@ -786,7 +786,14 @@ export class NCalendar extends NCalendarCommon { const localDate = jsDateToLocalDate(date); if (this.displayMode === DisplayMode.Month) { - if (animated) { + if (this.orientation === Orientation.Horizontal && this.scrollPaged) { + const ym = java.time.YearMonth.of(date.getFullYear(), date.getMonth() + 1); + if (animated) { + this._calendarView.smoothScrollToMonth(ym); + } else { + this._calendarView.scrollToMonth(ym); + } + } else if (animated) { this._calendarView.smoothScrollToDate(localDate); } else { this._calendarView.scrollToDate(localDate); @@ -870,19 +877,54 @@ export class NCalendar extends NCalendarCommon { [selectedDatesProperty.setNative](value: Date[]) { if (this._internalSelectionChange) return; this._selectedKeys.clear(); + const normalizedDates: Date[] = []; if (value && value.length) { for (const d of value) { - this._selectedKeys.add(this._toDateKey(d)); + const normalized = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + normalizedDates.push(normalized); + this._selectedKeys.add(this._toDateKey(normalized)); + } + } + + if (this.selectionMode === SelectionMode.Range) { + normalizedDates.sort((a, b) => a.getTime() - b.getTime()); + if (normalizedDates.length) { + this._rangeStart = normalizedDates[0]; + this._rangeEnd = normalizedDates[normalizedDates.length - 1]; + this._selectedKeys.clear(); + const cursor = new Date(this._rangeStart.getTime()); + while (cursor.getTime() <= this._rangeEnd.getTime()) { + this._selectedKeys.add(this._toDateKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + } else { + this._rangeStart = null; + this._rangeEnd = null; } + this._internalSelectionChange = true; + this._syncSelectedDateRange(); + this._internalSelectionChange = false; } + this._refreshCalendar(); + + if (normalizedDates.length) { + this.scrollToDate(normalizedDates[0], false); + } } [selectedDateRangeProperty.setNative](value: any) { if (this._internalSelectionChange) return; if (value && value.start && value.end) { - this._rangeStart = value.start; - this._rangeEnd = value.end; + const start = new Date(value.start.getFullYear(), value.start.getMonth(), value.start.getDate()); + const end = new Date(value.end.getFullYear(), value.end.getMonth(), value.end.getDate()); + if (start.getTime() <= end.getTime()) { + this._rangeStart = start; + this._rangeEnd = end; + } else { + this._rangeStart = end; + this._rangeEnd = start; + } this._selectedKeys.clear(); const cursor = new Date(this._rangeStart.getTime()); while (cursor.getTime() <= this._rangeEnd.getTime()) { @@ -895,6 +937,10 @@ export class NCalendar extends NCalendarCommon { this._selectedKeys.clear(); } this._refreshCalendar(); + + if (this._rangeStart) { + this.scrollToDate(this._rangeStart, false); + } } [eventsProperty.setNative](_value: any) { diff --git a/packages/nativescript-calendar/index.ios.ts b/packages/nativescript-calendar/index.ios.ts index 1b751fb..a572b3e 100644 --- a/packages/nativescript-calendar/index.ios.ts +++ b/packages/nativescript-calendar/index.ios.ts @@ -258,19 +258,54 @@ export class NCalendar extends NCalendarCommon { [selectedDatesProperty.setNative](value: Date[]) { if (this._internalSelectionChange) return; this._selectedKeys.clear(); + const normalizedDates: Date[] = []; if (value && value.length) { for (const d of value) { - this._selectedKeys.add(this._toDateKey(d)); + const normalized = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + normalizedDates.push(normalized); + this._selectedKeys.add(this._toDateKey(normalized)); } } + + if (this.selectionMode === SelectionMode.Range) { + normalizedDates.sort((a, b) => a.getTime() - b.getTime()); + if (normalizedDates.length) { + this._rangeStart = normalizedDates[0]; + this._rangeEnd = normalizedDates[normalizedDates.length - 1]; + this._selectedKeys.clear(); + const cursor = new Date(this._rangeStart.getTime()); + while (cursor.getTime() <= this._rangeEnd.getTime()) { + this._selectedKeys.add(this._toDateKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + } else { + this._rangeStart = null; + this._rangeEnd = null; + } + this._internalSelectionChange = true; + this._syncSelectedDateRange(); + this._internalSelectionChange = false; + } + this._syncSelectionToBridge(); + + if (normalizedDates.length) { + this.scrollToDate(normalizedDates[0], false); + } } [selectedDateRangeProperty.setNative](value: any) { if (this._internalSelectionChange) return; if (value && value.start && value.end) { - this._rangeStart = value.start; - this._rangeEnd = value.end; + const start = new Date(value.start.getFullYear(), value.start.getMonth(), value.start.getDate()); + const end = new Date(value.end.getFullYear(), value.end.getMonth(), value.end.getDate()); + if (start.getTime() <= end.getTime()) { + this._rangeStart = start; + this._rangeEnd = end; + } else { + this._rangeStart = end; + this._rangeEnd = start; + } this._selectedKeys.clear(); const cursor = new Date(this._rangeStart.getTime()); while (cursor.getTime() <= this._rangeEnd.getTime()) { @@ -283,6 +318,10 @@ export class NCalendar extends NCalendarCommon { this._selectedKeys.clear(); } this._syncSelectionToBridge(); + + if (this._rangeStart) { + this.scrollToDate(this._rangeStart, false); + } } [eventsProperty.setNative](value: any) { diff --git a/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift b/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift index e79b0c3..b051216 100644 --- a/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift +++ b/packages/nativescript-calendar/platforms/ios/src/NCalendarView.swift @@ -71,6 +71,11 @@ public class NCalendarView: UIView { let dowRowHeight: CGFloat = ceil(dayOfWeekFontSizePt * 1.8) + 10 let maxMonthHeight = dowRowHeight + 6 * dayWidth + 6 * verticalDayMarginPt calendarView.frame = CGRect(x: 0, y: labelHeight, width: bounds.width, height: maxMonthHeight) + + // Scroll callbacks are not guaranteed on first render, so seed the title now. + if !didInitializeHorizontalMonthLabel { + syncHorizontalMonthLabelToInitialVisibleMonth(notify: true) + } } else { horizontalMonthLabel?.isHidden = true calendarView.frame = bounds @@ -101,13 +106,17 @@ public class NCalendarView: UIView { // Horizontal mode month label private var horizontalMonthLabel: UILabel? + private var didInitializeHorizontalMonthLabel = false // MARK: - Configuration Properties @objc public var isHorizontal: Bool = false { didSet { guard oldValue != isHorizontal else { return } - if isHorizontal { ensureHorizontalMonthLabel() } + if isHorizontal { + ensureHorizontalMonthLabel() + didInitializeHorizontalMonthLabel = false + } recreateCalendarView() setNeedsLayout() } @@ -220,20 +229,43 @@ public class NCalendarView: UIView { guard let self = self else { return } let startDay = visibleDayRange.lowerBound let endDay = visibleDayRange.upperBound - self.updateHorizontalMonthLabel(year: startDay.month.year, month: startDay.month.month) - self.onScroll?(startDay.month.year, startDay.month.month, endDay.month.year, endDay.month.month, isDragging) + let visibleMonth = self.resolvePrimaryVisibleMonth(startDay: startDay, endDay: endDay) + self.updateHorizontalMonthLabel(year: visibleMonth.year, month: visibleMonth.month) + self.onScroll?(visibleMonth.year, visibleMonth.month, endDay.month.year, endDay.month.month, isDragging) } calendarView.didEndDecelerating = { [weak self] visibleDayRange in guard let self = self else { return } let startDay = visibleDayRange.lowerBound let endDay = visibleDayRange.upperBound - self.updateHorizontalMonthLabel(year: startDay.month.year, month: startDay.month.month) - self.onScrollEnd?(startDay.month.year, startDay.month.month, endDay.month.year, endDay.month.month) - self.onMonthChanged?(startDay.month.year, startDay.month.month) + let visibleMonth = self.resolvePrimaryVisibleMonth(startDay: startDay, endDay: endDay) + self.updateHorizontalMonthLabel(year: visibleMonth.year, month: visibleMonth.month) + self.onScrollEnd?(visibleMonth.year, visibleMonth.month, endDay.month.year, endDay.month.month) + self.onMonthChanged?(visibleMonth.year, visibleMonth.month) } } + private func resolvePrimaryVisibleMonth(startDay: DayComponents, endDay: DayComponents) -> (year: Int, month: Int) { + let fallback = (year: startDay.month.year, month: startDay.month.month) + + guard + let startDate = _calendar.date(from: DateComponents(year: startDay.month.year, month: startDay.month.month, day: startDay.day)), + let endDate = _calendar.date(from: DateComponents(year: endDay.month.year, month: endDay.month.month, day: endDay.day)) + else { + return fallback + } + + let lower = min(startDate.timeIntervalSinceReferenceDate, endDate.timeIntervalSinceReferenceDate) + let upper = max(startDate.timeIntervalSinceReferenceDate, endDate.timeIntervalSinceReferenceDate) + let midpoint = Date(timeIntervalSinceReferenceDate: lower + ((upper - lower) / 2.0)) + let comps = _calendar.dateComponents([.year, .month], from: midpoint) + + guard let year = comps.year, let month = comps.month else { + return fallback + } + return (year, month) + } + private func updateCalendar() { _calendar = Calendar.current _calendar.firstWeekday = firstDayOfWeekJS + 1 @@ -559,6 +591,10 @@ public class NCalendarView: UIView { showWeekContaining(date) } else { calendarView.scroll(toMonthContaining: date, scrollPosition: .firstFullyVisiblePosition, animated: animated) + if isHorizontal { + updateHorizontalMonthLabel(year: year, month: month) + onMonthChanged?(year, month) + } } } @@ -567,8 +603,18 @@ public class NCalendarView: UIView { guard let date = _calendar.date(from: components) else { return } if displayModeStr == "week" { showWeekContaining(date) + } else if isHorizontal && isPaginated { + // In horizontal paged mode, paging is month-based. Scrolling to day can be a no-op + // when the target day's month is not promoted to the leading page. + calendarView.scroll(toMonthContaining: date, scrollPosition: .firstFullyVisiblePosition, animated: animated) + updateHorizontalMonthLabel(year: year, month: month) + onMonthChanged?(year, month) } else { calendarView.scroll(toDayContaining: date, scrollPosition: .centered, animated: animated) + if isHorizontal { + updateHorizontalMonthLabel(year: year, month: month) + onMonthChanged?(year, month) + } } } @@ -617,6 +663,36 @@ public class NCalendarView: UIView { label.text = formatter.string(from: date) label.font = .boldSystemFont(ofSize: monthHeaderFontSizePt) label.textColor = colorFromHex(monthHeaderTextColorHex) ?? .label + didInitializeHorizontalMonthLabel = true + } + + private func syncHorizontalMonthLabelToInitialVisibleMonth(notify: Bool) { + guard isHorizontal else { return } + + let now = Date() + var anchorDate = now + + if minDateMs > 0 { + let minDate = Date(timeIntervalSince1970: minDateMs / 1000) + if anchorDate < minDate { + anchorDate = minDate + } + } + + if maxDateMs > 0 { + let maxDate = Date(timeIntervalSince1970: maxDateMs / 1000) + if anchorDate > maxDate { + anchorDate = maxDate + } + } + + let comps = _calendar.dateComponents([.year, .month], from: anchorDate) + guard let year = comps.year, let month = comps.month else { return } + + updateHorizontalMonthLabel(year: year, month: month) + if notify { + onMonthChanged?(year, month) + } } private func isDateDisabled(_ date: Date) -> Bool {