본문 바로가기

Develop/Flutter

[Flutter] 순환 캐러셀 (Circular Carousel) 위젯 만들기

728x90

1. 개요

1.1. 캐러셀 (Carousel)

캐러셀 (Carousel)회전목마라는 뜻으로 일련의 위젯을 슬라이드 쇼 형태로 나열해놓은 위젯을 가리킨다.

1.1.1. 일반 캐러셀

일반 캐러셀은 위와 같이 단일 위젯이 직선 위에서 이동하는 특징이 있다.

1.1.2. 순환 캐러셀

순환 캐러셀은 일반 캐러셀과 달리 단일 위젯이 타원을 그리면서 회전한다.

1.2. 순환 캐러셀의 필요

Flutter 로 개발 중인 어플리케이션 (Fitween) 이 있는데, 디자이너로부터 다음과 같은 위젯의 구현을 요청받았다.

1.3. 구현

관련 패키지가 있는지 확인해보았지만, 굉장히 적고, 존재하는 것들도 마음에 들지 않았다. 적당한 꼼수를 발휘해서, 정적 이미지와 동적 이미지 (gif) 를 미리 준비하고, 좌우 드래그 제스처가 감지되면 적절히 이미지가 교체되도록 하였다.

1.4. 한계

하지만 위와 같은 구현 방식은 다음과 같은 한계가 있었다.

  1. 회전하는 단일 위젯과의 상호작용 불가
  2. gif 특유의 색상 변질 현상 발생
  3. gif 의 최초 로딩 시간 지연에 따른 반짝임 현상 발생
  4. 느린 반응 및 불편한 사용자 경험
    등등...

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