1. 개요
1.1. 캐러셀 (Carousel)
캐러셀 (Carousel) 은 회전목마라는 뜻으로 일련의 위젯을 슬라이드 쇼 형태로 나열해놓은 위젯을 가리킨다.
1.1.1. 일반 캐러셀
![]()
일반 캐러셀은 위와 같이 단일 위젯이 직선 위에서 이동하는 특징이 있다.
1.1.2. 순환 캐러셀
![]()
순환 캐러셀은 일반 캐러셀과 달리 단일 위젯이 타원을 그리면서 회전한다.
1.2. 순환 캐러셀의 필요
Flutter 로 개발 중인 어플리케이션 (Fitween) 이 있는데, 디자이너로부터 다음과 같은 위젯의 구현을 요청받았다.
![]()
1.3. 구현
관련 패키지가 있는지 확인해보았지만, 굉장히 적고, 존재하는 것들도 마음에 들지 않았다. 적당한 꼼수를 발휘해서, 정적 이미지와 동적 이미지 (gif) 를 미리 준비하고, 좌우 드래그 제스처가 감지되면 적절히 이미지가 교체되도록 하였다.

1.4. 한계
하지만 위와 같은 구현 방식은 다음과 같은 한계가 있었다.
- 회전하는 단일 위젯과의 상호작용 불가
gif특유의 색상 변질 현상 발생gif의 최초 로딩 시간 지연에 따른 반짝임 현상 발생- 느린 반응 및 불편한 사용자 경험
등등...
1.5. 결론
직접 만들자!!
2. 원리
2.1. 설명
원리는 다음과 같다.
- 실제 3차원 공간에서의 회전을 다음의 이미지와 같이 2차원 화면에 사영시킨다.
![]()
- 위젯이 $n$ 개, 각 단일 위젯의 색인이 $i$ 일 때, $2 \pi i / n \ \text{rad}$ 의 각도를 따라 배치하게 된다.
- 드래그 제스처가 감지되면, 위젯은 타원의 자취를 그리며 회전을 한다.
- 이 때, 각 위젯 간 각도 간격은 유지해야 한다.
- 화면 터치 중: 움직이는 만큼 회전한다.
- 화면 터치 떼는 순간: 드래그 최종 순간속도에 맞추어 회전하다가 서서히 멈춘다.
- 완전히 멈춘 후 가장 가까운 $2 \pi i / n \ \text{rad}$ 각도에 대한 위치로 자동 이동한다.
2.2. 수식
먼저 타원의 장반지름과 단반지름을 각각 $a$, $b$ 라고 하자.
위젯이 3개 즉, $n = 3$ 일 때, 각 점의 좌표 ($P_i$) 는 다음과 같다.
- $P_0 = (0, b)$
- $P_1 = (a \sin { 2 \pi \over 3 }, b \cos { 2 \pi \over 3 })$
- $P_2 = (a \sin { 4 \pi \over 3 }, b \cos { 4 \pi \over 3 })$
![]()
이를 일반화 하여 다음과 같이 나타낼 수 있다.
$$P_i = (a \sin { 2 \pi \over 3 } i, b \cos { 4 \pi \over 3 } i)$$
2.3. 필요 함수
실제로 구현할 때 필요한 함수를 나열하면 다음과 같다.
_setAngle(angle: double): void- 각도를 설정한다.
_moveByAngle(dt: double): void- 각도 변화값 ($\text{d} t$) 을 적용한다.
_getPos(index: int): Offset- 특정 색인 (index) 의 위치값을 반환한다.
_fitPos(angle: double): void- 회전이 정지된 후 가장 가까운 $2 \pi i / n \text{rad}$ 각도로 이동한다.
_compare(a: _CircularCarouselEntry, b: _CircularCarouselEntry): int- 단일 위젯의 $y$ 좌표에 따라 z-index 값 설정을 위한 정렬 비교 함수이다.
- $y$ 좌표 값이 큰 순서대로 위젯이 앞에 위치해야 한다.
3. 구현
3.1. 코드
다음과 같이 Flutter 로 구현하였다.
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
class CircularCarousel extends StatefulWidget {
const CircularCarousel({
super.key,
required this.children,
required this.width,
this.itemSize,
this.height,
this.leftWidget,
this.rightWidget,
this.onChanged,
this.defaultIndex = 0,
});
final List<Widget> children;
final double width;
final double? height;
final double? itemSize;
final Widget? leftWidget;
final Widget? rightWidget;
final Function(int)? onChanged;
final int defaultIndex;
@override
State<CircularCarousel> createState() => _CircularCarouselState();
}
class _CircularCarouselState extends State<CircularCarousel> {
int get _length => widget.children.length;
double get _width => widget.width;
double get _height => _orbitHeight + _itemSize;
double get _itemSize => widget.itemSize ?? _width * .5;
double get _orbitWidth => _width * .6;
double get _orbitHeight => max(widget.height ?? .001, .001);
double get _a => _orbitWidth * .5;
double get _b => _orbitHeight * .5;
late double velocity;
int get _index => (_angle / _dAngle).round();
Offset _getPos(int index) {
double angle = (_angle + _dAngle * index) % (2 * pi);
double x = _a * sin(angle) + _width * .5;
double y = _b * cos(angle) + _height * .5;
return Offset(x, y);
}
void _setAngle(double angle) => setState(() => _angle = angle);
void _moveByAngle(double dt) {
_setAngle((_angle + dt) % (2 * pi));
}
double _angle = .0;
double get _dAngle => 2 * pi / _length;
double get _nearAngle {
double err = 2 * pi;
late int index;
for (int i = 0; i < _length + 1; i++) {
double e = (_dAngle * i - _angle).abs();
if (err < e) continue;
err = e; index = i;
}
return _dAngle * index;
}
void _onHorizontalDragUpdate(DragUpdateDetails details) {
_moveByAngle(details.delta.dx * .01);
}
void _onHorizontalDragEnd(DragEndDetails details) async {
velocity = details.primaryVelocity!;
if (velocity.abs() == 0) { _fitPos(_nearAngle); return; }
velocity *= .00001;
Timer.periodic(const Duration(milliseconds: 10), (timer) {
setState(() => velocity *= .99);
if (velocity.abs() < .01) {
timer.cancel(); _fitPos(_nearAngle);
return;
}
_moveByAngle(velocity);
});
}
bool _buttonVisible = true;
void _hideButtons() => setState(() => _buttonVisible = false);
void _showButtons() => setState(() => _buttonVisible = true);
void _fitPos(double angle) {
Timer.periodic(const Duration(milliseconds: 10), (timer) {
double err = angle - _angle;
if (err.abs() > pi) err *= -1;
if (err.abs() < .05) {
timer.cancel();
_setAngle(_nearAngle);
if (widget.onChanged != null) {
int index = _index;
if (_index == 0) index += _length;
widget.onChanged!(_length - index);
_showButtons();
}
return;
}
_moveByAngle(.05 * err.sign);
});
}
int _compare(_CircularCarouselEntry a, _CircularCarouselEntry b) {
return a.pos.dy.compareTo(b.pos.dy);
}
List<_CircularCarouselEntry> get entries => widget
.children.asMap().entries.map((entry) => _CircularCarouselEntry(
pos: _getPos(entry.key),
child: entry.value,
size: _itemSize,
)).toList();
List<Widget> get _widgets {
List<_CircularCarouselEntry> es = [...entries];
es.sort(_compare);
return es.map((e) => e.toWidget()).toList();
}
void _leftButtonPressed() {
_hideButtons();
int next = (_index + 1) % _length;
_fitPos(_dAngle * next);
}
void _rightButtonPressed() {
_hideButtons();
int next = (_index - 1) % _length;
_fitPos(_dAngle * next);
}
@override
void initState() {
super.initState();
_setAngle((_length - widget.defaultIndex) * _dAngle);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return GestureDetector(
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
child: Builder(
builder: (context) {
double left = (size.width - _width) * .5;
double right = left + _width;
double leftArrowPos = left + _width * .5 - _itemSize * .75;
double rightArrowPos = right - _width * .5 - _itemSize * .75;
return Stack(
children: [
Positioned(
left: left, top: .0,
child: SizedBox(
width: _width,
height: _height,
child: Stack(children: _widgets),
),
),
if (_buttonVisible && widget.leftWidget != null)
Positioned(
left: leftArrowPos, top: _height * .4,
child: GestureDetector(
onTap: _leftButtonPressed,
child: widget.leftWidget!,
),
),
if (_buttonVisible && widget.rightWidget != null)
Positioned(
right: rightArrowPos, top: _height * .4,
child: GestureDetector(
onTap: _rightButtonPressed,
child: widget.rightWidget!,
),
),
],
);
},
),
);
}
}
class _CircularCarouselEntry {
late Offset pos;
late Widget child;
late double size;
_CircularCarouselEntry({
required this.pos,
required this.child,
required this.size,
});
Widget toWidget() {
return Positioned.fromRect(
rect: Rect.fromCenter(
center: pos,
width: size,
height: size,
), child: child,
);
}
}
3.2. 결과

4. 적용

어플리케이션에 적용한 모습이다.
728x90
'Develop > Flutter' 카테고리의 다른 글
| [Flutter] 한글 Utility 기능 (0) | 2025.01.28 |
|---|---|
| [Flutter] 문자열 명명 형식 변환기 (String Case Converter) (0) | 2024.11.29 |
| [Flutter] Duration 값 간결히 나타내기 (0) | 2023.11.01 |
| [Flutter] 토스 스타일의 Pressable 커스텀 위젯 만들기 (0) | 2023.09.23 |