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.
- 할일 2개(내용 약 210px) → needed 282 → 0.31 → 0.6
- 할일 6개(내용 약 498px) → needed 570 → 0.63 (최대 아님)
- 할일 20개(내용 약 1506px) → 0.9를 넘음 → 0.9, 나머지는 스크롤
9월 23일처럼 기록·일정·할일이 섞여 12개인 날도 같다. 셀어 보면 최대 전에 끝나면 그 높이, 최대에서도 잘리면 0.9다.
2. 고친 원인
아이폰 day_plan_appl_app 의 lib/screens/calendar_tab.dart 에 2026-09-23 반영됨.
- 목록이 시트 크기용
ScrollController에 붙어 있었다. 휠은 목록 대신 시트 높이만 바꾸거나, 데스크톱에서는 먹지 않았다. - 날짜칸은
size < 0.6일 때만 0.6으로 올렸다. 한 번 0.9까지 올리면 다음 날짜도 그 높이·스크롤로 다시 열렸다. - 날짜+건수는 0.6과 접힘을 오갔다. 내용을 최대한 보이게 하지 못했다.
구조는 이렇게 나눈다.
- 시트 높이:
DraggableScrollableController. 1px짜리 스크롤에만 붙여 휠을 받지 않는다. - 목록: 별도
ScrollController. 휠·터치가 항목만 움직인다. - 핸들과 날짜+건수는 목록 밖. 스크롤해도 위에 남는다.
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);
}
},
),
),
]),
),
)
_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';
}
scrollHeight가 아이폰의 측정값과 같은 역할이다.
안 그래도 식이 같아야 한다. 기본 0.6 미만으로 내리지 않고, 0.9에서도 잘릴 때만 0.9다.
5. 확인
안드로이드 폰(또는 윈도우에서 띄운 앱)과 웹에서 각각 본다. 9월 23일처럼 항목이 많은 날과, 항목이 1~2개인 날을 둘 다 누른다.
- 항목이 적은 날: 시트가 기본 높이로 열린다. 기본보다 낮지 않다. 목록은 맨 위다.
- 기본 높이보다 길지만 최대 안에 들어가는 날: 최대까지 가지 않는다. 마지막 항목 아래에 여백만 조금 있다.
- 최대 높이로도 아래가 남는 날(예: 12개 이상): 최대 높이로 열리고, 휠 또는 손가락으로 목록만 움직인다. 시트 높이는 휠로 커지지 않는다.
- 시트를 끝까지 올린 뒤 닫고 같은 날짜를 다시 누른다. 마지막 높이가 아니라 그 날 항목에 맞는 높이로, 목록은 맨 위부터 나온다.
- 날짜+건수를 누르면 최대 높이. 한 번 더 누르면 접힌다.
- 핸들을 드래그하면 높이만 바뀐다. 날짜와 건수 글자는 목록을 스크롤해도 위에 남는다.
- 다른 날짜를 누르면 이전 날짜의 스크롤 위치가 따라오지 않는다.