달력 바텀시트 높이·스크롤

2026-09-23 (KST). 아이폰 앱 day_plan_appl_app 의 lib/screens/calendar_tab.dart 에 반영된 최종 동작이다. 윈도우에서 연 안드로이드 day_plan_app 과 웹 app_schedule(DEV_ModamCore) 에는 이 문서를 보고 같은 규칙을 넣는다. 이 문서를 쓰는 시점에는 그 두 곳에 코드를 넣지 않았다.

1. 최종 규칙

달력 아래 시트다. 높이는 부모(달력 화면) 높이의 비율이다.

상수값의미
기본 높이0.6날짜칸을 열 때 이보다 낮아지지 않는다.
최대 높이0.9날짜+건수를 누르거나, 이 높이로도 아래가 잘릴 때.
헤더48px핸들 + 날짜·건수. 목록 높이를 계산할 때 시트에서 뺀다.
여유24px항목 높이보다 시트 목록 영역을 이만큼만 더 준다.
조작결과
날짜칸 클릭 목록은 맨 위. 높이는 항목 높이 + 여유 24px + 헤더 48px. 그 비율이 0.6 이하면 0.6. 0.9 이상이면 0.9. 이전에 끌어올린 높이·스크롤 위치는 남기지 않는다.
항목이 적어도 기본 높이 0.6보다 낮게 열지 않는다.
기본보다 길지만 최대 안에 들어가면 최대까지 올리지 않는다. 마지막 항목 아래로 여유만 조금 두고 그 높이에서 멈춘다.
최대 높이로도 아래가 잘리면 0.9까지 올리고, 나머지는 목록 스크롤로 본다.
날짜+건수 클릭 최대가 아니면 0.9. 이미 최대(0.88 이상)면 접힘 높이로 내린다.
마우스 휠·터치 스크롤 시트 높이는 그대로 두고 목록만 움직인다.
위 핸들 드래그 시트 높이만 조절한다. 접힘 ~ 0.9.
닫기(X) 접힘 + 목록 맨 위.
중간에 쓰였던 “항목이 많으면 무조건 최대 높이”는 최종이 아니다. docs/2026-09-23/20260923_1006_바텀시트_항목많으면_최대높이.md 는 따르지 않는다. 이 HTML이 최종이다.

높이 식. bodyHeight는 달력 화면 높이(px), contentHeight는 목록 내용 높이(px).

needed = contentHeight + 24 + 48
size   = needed / bodyHeight
size <= 0.6 이면 0.6
size >= 0.9 이면 0.9
그 사이면 size 그대로

예. 화면 높이 900px.

9월 23일처럼 기록·일정·할일이 섞여 12개인 날도 같다. 셀어 보면 최대 전에 끝나면 그 높이, 최대에서도 잘리면 0.9다.

2. 고친 원인

아이폰 day_plan_appl_app 의 lib/screens/calendar_tab.dart 에 2026-09-23 반영됨.

구조는 이렇게 나눈다.

3. 안드로이드 day_plan_app

윈도우에서 연다. 파일은 아이폰과 같은 lib/screens/calendar_tab.dart 다. 달력 파일 전체를 덮어쓰지 않는다. 아래 조각만 넣는다. 핫 리로드 대신 앱을 완전히 다시 실행한다.

윈도우 소스는 아직 예전 시트다. 찾을 코드:

void _raiseSheet() {
  const target = 0.6;
  ...
  if (_sheetCtl.isAttached && _sheetCtl.size < target) {
    _sheetCtl.animateTo(target, ...);
  }
}

void _toggleSheet() {
  ...
  final target = _sheetCtl.size < 0.5 ? 0.6 : collapsed;
}

ListView(
  controller: sc,   // DraggableScrollableSheet 가 넘겨 준 컨트롤러
  ...
)

3.1 파일 상단, CalendarTab 클래스 앞에 넣는다

import 다음, 클래스 선언 전이다.

/// 날짜칸을 눌렀을 때 시트가 처음 올라오는 높이(부모 높이 대비).
const double kCalendarSheetRaised = 0.6;

/// 날짜+건수를 눌렀을 때, 또는 최대 높이로도 목록이 잘릴 때의 높이.
const double kCalendarSheetMax = 0.9;

/// 핸들·날짜 줄. 목록이 차지하는 높이를 시트 전체에서 뺄 때 쓴다.
const double kCalendarSheetHeaderH = 48;

/// 항목 아래에 두는 여유. 시트는 목록보다 이만큼만 더 높다.
const double kCalendarSheetContentSlack = 24;

/// 항목 높이보다 조금만 더 올린다.
/// 기본 높이(0.6)보다 낮아지지는 않고, 최대 높이로도 잘리면 0.9에서 멈춘다.
double calendarSheetSizeOnDayTap({
  required double contentHeight,
  required double bodyHeight,
}) {
  if (bodyHeight <= 0 || contentHeight <= 0) return kCalendarSheetRaised;
  final needed = contentHeight +
      kCalendarSheetContentSlack +
      kCalendarSheetHeaderH;
  final size = needed / bodyHeight;
  if (size <= kCalendarSheetRaised) return kCalendarSheetRaised;
  if (size >= kCalendarSheetMax) return kCalendarSheetMax;
  return size;
}

/// 시트 목록의 대략적인 높이(패딩·구역 제목·행).
double calendarSheetContentHeight({
  required int eventCount,
  required int recordCount,
  required int todoCount,
  required int gcalCount,
  required bool showEvents,
  required bool showRecords,
  required bool showTodos,
  bool hasHoliday = false,
}) {
  var h = 32.0;
  if (hasHoliday) h += 28;
  if (showTodos && gcalCount > 0) h += 34 + gcalCount * 44;
  if (showEvents && eventCount > 0) h += 34 + eventCount * 44;
  if (showRecords && recordCount > 0) h += 34 + recordCount * 44;
  if (showTodos) {
    h += 34;
    h += todoCount == 0 ? 24 : todoCount * 72;
  }
  return h;
}

/// 날짜+건수: 최대가 아니면 최대까지, 이미 최대면 접힘 높이로.
double calendarSheetSizeOnCountTap({
  required double current,
  required double collapsed,
}) {
  final atMax = current >= kCalendarSheetMax - 0.02;
  return atMax ? collapsed : kCalendarSheetMax;
}

3.2 상태 필드

_sheetCtl 옆에 목록 스크롤과 화면 높이를 둔다. dispose에서 _listCtl.dispose()를 호출한다.

final DraggableScrollableController _sheetCtl =
    DraggableScrollableController();
// 시트 크기와 분리된 목록 스크롤. 휠이 시트를 키우지 않고 항목을 움직이게 함.
final ScrollController _listCtl = ScrollController();
double _lastCollapsedFrac = -1;
double _bodyH = 0;
// 날짜칸을 누른 날. 로드가 끝난 뒤 그 날 항목으로 높이를 다시 맞춘다.
String? _pendingSheetDay;
@override
void dispose() {
  ...
  _sheetCtl.dispose();
  _listCtl.dispose();
  ...
}

3.3 _raiseSheet / _toggleSheet 를 이 묶음으로 바꾼다

void _resetListScroll() {
  if (!_listCtl.hasClients) return;
  if (_listCtl.offset > 0) _listCtl.jumpTo(0);
}

bool get _showEvents =>
    !_recordsOnly && !_todosOnly && !_holidaysOnly;

bool get _showRecords =>
    !_eventsOnly && !_todosOnly && !_holidaysOnly;

bool get _showTodos =>
    !_eventsOnly && !_recordsOnly && !_holidaysOnly;

/// 현재 뷰포트보다 목록이 길면 그 전체 높이. 잘리지 않으면 null.
double? _overflowContentHeight() {
  if (!_listCtl.hasClients) return null;
  final pos = _listCtl.position;
  if (!pos.hasContentDimensions || pos.maxScrollExtent <= 1) return null;
  return pos.maxScrollExtent + pos.viewportDimension;
}

double _openSheetSize({required bool fromLists, required String iso}) {
  final eventCount =
      fromLists ? _selEvents.length : (_eventsByDate[iso]?.length ?? 0);
  final recordCount =
      fromLists ? _selRecords.length : (_recordsByDate[iso]?.length ?? 0);
  final todoCount =
      fromLists ? _selItems.length : (_todosByDate[iso]?.length ?? 0);
  final gcalCount = _gcal[iso]?.length ?? 0;
  var content = calendarSheetContentHeight(
    eventCount: eventCount,
    recordCount: recordCount,
    todoCount: todoCount,
    gcalCount: gcalCount,
    showEvents: _showEvents,
    showRecords: _showRecords,
    showTodos: _showTodos,
    hasHoliday: Holidays.nameOf(iso) != null && !_repeatOnly,
  );
  if (fromLists) {
    final measured = _overflowContentHeight();
    if (measured != null && measured > content) content = measured;
  }
  return calendarSheetSizeOnDayTap(
    contentHeight: content,
    bodyHeight: _bodyH,
  );
}

void _raiseSheet({required bool fromLists, required String iso}) {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (!mounted || !_sheetCtl.isAttached) return;
    if (isoOf(_selected) != iso) return;
    _resetListScroll();
    final target = _openSheetSize(fromLists: fromLists, iso: iso);
    if ((_sheetCtl.size - target).abs() > 0.01) {
      _sheetCtl.animateTo(
        target,
        duration: const Duration(milliseconds: 250),
        curve: Curves.easeOut,
      );
    }
  });
}

void _schedulePendingSheetOpen() {
  if (_pendingSheetDay != isoOf(_selected) || _loadAgain) return;
  final day = _pendingSheetDay!;
  _pendingSheetDay = null;
  if (!_hasSheetContentForSelected()) return;
  _raiseSheet(fromLists: true, iso: day);
}

void _toggleSheet() {
  if (!_sheetCtl.isAttached) return;
  final collapsed = _lastCollapsedFrac > 0 ? _lastCollapsedFrac : 0.06;
  final target = calendarSheetSizeOnCountTap(
    current: _sheetCtl.size,
    collapsed: collapsed,
  );
  Future.delayed(const Duration(milliseconds: 150), () {
    if (!mounted || !_sheetCtl.isAttached) return;
    _sheetCtl.animateTo(target,
        duration: const Duration(milliseconds: 250), curve: Curves.easeOut);
  });
}
fromLists: false는 이미 그려진 칸의 개수(기록·일정·할일 맵)로 바로 연다. 로드가 끝나면 fromLists: true로 실제 목록 높이를 다시 맞춘다. 측정값이 추정보다 크면(제목이 두 줄) 측정값을 쓴다. _loadAgain이 켜져 있으면 이번 로드는 건너뛰고, 이어지는 로드가 높이를 맞춘다.

3.4 호출 위치

build의 LayoutBuilder 첫 줄에서 화면 높이를 저장한다.

final bodyH = constraints.maxHeight;
_bodyH = bodyH;

날짜칸. 예전 _raiseSheet()와 _load().then 를 지우고 이것으로 바꾼다.

onDaySelected: (sel, foc) {
  setState(() {
    _selected = sel;
    _focused = foc;
  });
  final si = isoOf(sel);
  _pendingSheetDay = si;
  if (_hasCellContent(si)) {
    _raiseSheet(fromLists: false, iso: si);
  }
  _load();
},

일반 달력 로드 setState 직후, 그리고 반복보기 _loadRepeatMode의 setState 직후에 한 줄씩 넣는다.

setState(() {
  ...
  _selItems = ...
  _selEvents = ...
  _selRecords = ...
});
_schedulePendingSheetOpen();

시트 maxChildSize: 0.9는 maxChildSize: kCalendarSheetMax로 바꾼다. 핸들 드래그의 clamp(..., 0.9)도 kCalendarSheetMax로 바꾼다.

3.5 시트 위젯 구조

핸들과 날짜+건수는 Column에 두고, 그 아래 Expanded + ListView만 스크롤한다. ListView의 controller는 sc가 아니라 _listCtl이다. sc는 1×1, IgnorePointer, NeverScrollableScrollPhysics에만 붙인다. 목록의 자식(공휴일·구글·일정·기록·할일)은 기존 ListView children에서 핸들과 날짜+건수 GestureDetector를 뺀 나머지다.

DraggableScrollableSheet(
  key: const ValueKey('calendar-sheet'),
  controller: _sheetCtl,
  initialChildSize: sheetInit,
  minChildSize: collapsedFrac,
  maxChildSize: kCalendarSheetMax,
  builder: (ctx, sc) => Container(
    decoration: const BoxDecoration(
      color: Color(0xFFF7F9FC),
      borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
      boxShadow: [
        BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, -2)),
      ],
    ),
    child: Stack(children: [
      Column(children: [
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onVerticalDragUpdate: (d) {
            if (!_sheetCtl.isAttached) return;
            final h = bodyH <= 0 ? 1.0 : bodyH;
            final s = (_sheetCtl.size - d.delta.dy / h)
                .clamp(collapsedFrac, kCalendarSheetMax);
            _sheetCtl.jumpTo(s);
          },
          child: Padding(
            padding: const EdgeInsets.symmetric(vertical: 8),
            child: Center(
              child: Container(
                width: 40,
                height: 4,
                decoration: BoxDecoration(
                  color: Colors.grey.shade400,
                  borderRadius: BorderRadius.circular(2),
                ),
              ),
            ),
          ),
        ),
        GestureDetector(
          onTap: _toggleSheet,
          behavior: HitTestBehavior.opaque,
          child: Padding(
            padding: const EdgeInsets.fromLTRB(36, 0, 36, 4),
            child: Text('$selIso ($selCount건)',
                textAlign: TextAlign.center,
                style: const TextStyle(
                    fontSize: 16, fontWeight: FontWeight.bold)),
          ),
        ),
        Expanded(
          child: ListView(
            controller: _listCtl,
            primary: false,
            padding: const EdgeInsets.fromLTRB(16, 4, 16, 28),
            children: [
              // 기존 목록: 공휴일, 구글, 일정, 기록, 할일
            ],
          ),
        ),
      ]),
      Positioned(
        left: 0,
        top: 0,
        width: 1,
        height: 1,
        child: IgnorePointer(
          child: SingleChildScrollView(
            controller: sc,
            physics: const NeverScrollableScrollPhysics(),
            child: const SizedBox(height: 1),
          ),
        ),
      ),
      Positioned(
        top: 2,
        right: 2,
        child: IconButton(
          icon: const Icon(Icons.close),
          iconSize: 20,
          tooltip: '닫기',
          onPressed: () {
            _resetListScroll();
            if (_sheetCtl.isAttached) {
              final collapsed = _lastCollapsedFrac > 0
                  ? _lastCollapsedFrac
                  : collapsedFrac;
              _sheetCtl.animateTo(collapsed,
                  duration: const Duration(milliseconds: 250),
                  curve: Curves.easeOut);
            }
          },
        ),
      ),
    ]),
  ),
)
1px 스크롤을 빼면 _sheetCtl.isAttached가 거짓이 되어 animateTo가 동작하지 않는다. 휠을 먹게 하려고 그 컨트롤러를 목록에 다시 붙이지 않는다.

3.6 테스트

test/calendar_sheet_test.dart를 아이폰과 같이 둔다. 핵심만:

test('항목이 기본 높이에 들어가면 처음 높이', () {
  final content = calendarSheetContentHeight(
    eventCount: 0, recordCount: 0, todoCount: 2, gcalCount: 0,
    showEvents: true, showRecords: true, showTodos: true,
  );
  expect(
    calendarSheetSizeOnDayTap(contentHeight: content, bodyHeight: 900),
    kCalendarSheetRaised,
  );
});

test('기본보다 길지만 최대 안에 들어가면 항목보다 조금만 더 높다', () {
  const body = 900.0;
  final content = calendarSheetContentHeight(
    eventCount: 0, recordCount: 0, todoCount: 6, gcalCount: 0,
    showEvents: true, showRecords: true, showTodos: true,
  );
  final size = calendarSheetSizeOnDayTap(
    contentHeight: content, bodyHeight: body,
  );
  expect(size, greaterThan(kCalendarSheetRaised));
  expect(size, lessThan(kCalendarSheetMax));
  final listH = body * size - kCalendarSheetHeaderH;
  expect(listH, closeTo(content + kCalendarSheetContentSlack, 0.1));
});

test('최대 높이로도 잘리면 최대까지', () {
  final overflow = calendarSheetContentHeight(
    eventCount: 0, recordCount: 0, todoCount: 20, gcalCount: 0,
    showEvents: true, showRecords: true, showTodos: true,
  );
  expect(
    calendarSheetSizeOnDayTap(contentHeight: overflow, bodyHeight: 900),
    kCalendarSheetMax,
  );
});

실행: flutter test test/calendar_sheet_test.dart

4. 웹 app_schedule

달력 날짜를 누르면 아래에 뜨는 상세 패널에 같은 규칙을 넣는다. 파일 이름은 웹 저장소의 달력 스크립트를 따른다. 패널 높이를 이전 클릭 값으로 저장해 두었다가 다시 쓰지 않는다. 클릭할 때마다 다시 계산한다.

패널은 세로로 두 칸이다. 위는 핸들과 날짜+건수(스크롤되지 않음). 아래 목록만 overflow-y: auto다.

.cal-sheet {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: #f7f9fc;
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, .15);
  /* 접힘. 날짜칸을 누르면 JS가 height를 다시 쓴다. */
  height: 84px;
  max-height: 90%;
}
.cal-sheet .head { flex: 0 0 auto; text-align: center; font-weight: 700; }
.cal-sheet .list { flex: 1 1 auto; overflow-y: auto; min-height: 0; }

4.1 높이 함수

아이폰 calendarSheetSizeOnDayTap과 같다. 반환값은 부모 높이 대비 비율(0.6 ~ 0.9).

var SHEET_RAISED = 0.6;
var SHEET_MAX = 0.9;
var SHEET_HEADER_H = 48;
var SHEET_SLACK = 24;

function sheetSizeOnDayTap(contentHeight, bodyHeight) {
  if (!(bodyHeight > 0) || !(contentHeight > 0)) return SHEET_RAISED;
  var size = (contentHeight + SHEET_SLACK + SHEET_HEADER_H) / bodyHeight;
  if (size <= SHEET_RAISED) return SHEET_RAISED;
  if (size >= SHEET_MAX) return SHEET_MAX;
  return size;
}

function openDaySheet(sheet, list, body) {
  list.scrollTop = 0;
  var contentH = list.scrollHeight;
  var size = sheetSizeOnDayTap(contentH, body.clientHeight);
  var px = Math.round(size * body.clientHeight);
  var maxPx = Math.round(SHEET_MAX * body.clientHeight);
  var minPx = Math.round(SHEET_RAISED * body.clientHeight);
  if (px < minPx) px = minPx;
  if (px > maxPx) px = maxPx;
  sheet.style.height = px + 'px';
}

list.scrollHeight는 목록이 잘려 있어도 내용 전체 높이다. 날짜 데이터를 그린 다음에 openDaySheet를 호출한다. 한 프레임 뒤(requestAnimationFrame)에 부르면 방금 넣은 행의 높이가 반영된다.

function onDayCellClick(iso) {
  renderDayList(iso);          // 기존: 그 날 기록·일정·할일을 .list 에 그림
  requestAnimationFrame(function () {
    openDaySheet(sheetEl, listEl, calendarBodyEl);
  });
}

4.2 날짜+건수, 닫기, 휠

function onDateCountClick(sheet, body) {
  var maxPx = Math.round(SHEET_MAX * body.clientHeight);
  var atMax = sheet.offsetHeight >= maxPx - 4;
  sheet.style.height = (atMax ? 84 : maxPx) + 'px';
}

function closeDaySheet(sheet, list) {
  list.scrollTop = 0;
  sheet.style.height = '84px';
}

목록에 overflow-y: auto가 있으면 마우스 휠은 그 목록만 움직인다. 시트 높이를 휠로 키우는 핸들러가 있으면 목록 위에서는 그 핸들러를 타지 않게 한다. 높이 조절은 위 핸들 드래그만 한다. 드래그 중 높이는 84px ~ 화면의 90%로 제한한다.

function onHandleDrag(sheet, body, deltaY) {
  var next = sheet.offsetHeight - deltaY;
  var minPx = 84;
  var maxPx = Math.round(SHEET_MAX * body.clientHeight);
  if (next < minPx) next = minPx;
  if (next > maxPx) next = maxPx;
  sheet.style.height = next + 'px';
}
웹은 추정 행 높이(할일 72px, 일정·기록 44px)를 쓰지 않아도 된다. 그려진 목록의 scrollHeight가 아이폰의 측정값과 같은 역할이다. 안 그래도 식이 같아야 한다. 기본 0.6 미만으로 내리지 않고, 0.9에서도 잘릴 때만 0.9다.

5. 확인

안드로이드 폰(또는 윈도우에서 띄운 앱)과 웹에서 각각 본다. 9월 23일처럼 항목이 많은 날과, 항목이 1~2개인 날을 둘 다 누른다.

  1. 항목이 적은 날: 시트가 기본 높이로 열린다. 기본보다 낮지 않다. 목록은 맨 위다.
  2. 기본 높이보다 길지만 최대 안에 들어가는 날: 최대까지 가지 않는다. 마지막 항목 아래에 여백만 조금 있다.
  3. 최대 높이로도 아래가 남는 날(예: 12개 이상): 최대 높이로 열리고, 휠 또는 손가락으로 목록만 움직인다. 시트 높이는 휠로 커지지 않는다.
  4. 시트를 끝까지 올린 뒤 닫고 같은 날짜를 다시 누른다. 마지막 높이가 아니라 그 날 항목에 맞는 높이로, 목록은 맨 위부터 나온다.
  5. 날짜+건수를 누르면 최대 높이. 한 번 더 누르면 접힌다.
  6. 핸들을 드래그하면 높이만 바뀐다. 날짜와 건수 글자는 목록을 스크롤해도 위에 남는다.
  7. 다른 날짜를 누르면 이전 날짜의 스크롤 위치가 따라오지 않는다.