HOME
NOTE

[Flutter] Syncfusion Chart 커스텀 렌더러 사용 기록

CREATED
2025. 4. 14. 오후 6:36:33
UPDATED
2025. 4. 14. 오후 7:46:10
TAGS
#Flutter#Syncfusion Chart

공식 문서에 그라데이션을 적용하는 방법이 소개되어 있긴 하지만, 디자인 구현을 위해 커스텀 렌더러를 문서에 나온 것 이상으로 더 깊게 건드리는 과정에서 여러 문제를 겪음

  • 값이 음수인 경우 gradient 방향을 반대로 하는 옵션 제공하지 않음
  • 마찬가지로 border radius로 방향을 반대로 설정하는 옵션 없음
  • 상기 두 요구사항을 달성하기 위한 커스텀 과정에서 애니메이션 적용 어려움
  • 여러 차트를 한 틱에서 보여줄 때 순서가 바뀌는 문제를 해결 필요
  • 목표는 ColumnSeries로 요구사항 구현

문제 1. 음수일 때 그라데이션 효과 반대 방향으로 적용하기

onPaint를 오버라이딩
class _ColumnCustomPainter extends ColumnSegment<ChartData, DateTime> {
  @override
  void onPaint(Canvas canvas) {
    // 원래 구현 호출 (트래커 바운드 및 기본 렌더링)
    super.onPaint(canvas);
    
    // segmentRect가 없으면 아무것도 그리지 않음
    if (segmentRect == null) return;
    
    // 이미 그려진 컬럼 위에 그라디언트로 다시 그리기
    final Paint gradientPaint = Paint()..style = PaintingStyle.fill;
    final double y = series.dataSource![currentSegmentIndex].y;
    final Rect rect = segmentRect!.outerRect;
    
    gradientPaint.shader = ui.Gradient.linear(
      rect.topCenter,
      rect.bottomCenter,
      y >= 0 
          ? [MyColors.blue1, MyColors.gray3]
          : [MyColors.blue2, MyColos.gray3],
      [0.0, 1.0],
    );
    
    // 동일한 segmentRect를 그라디언트로 다시 그림
    canvas.drawRRect(segmentRect!, gradientPaint);
}

문제 2. 음수일 때 border radius 반대 방향으로 적용하기

class _ColumnCustomPainter extends ColumnSegment<ChartData, DateTime> {
  @override
  void onPaint(Canvas canvas) {
    // 원래 구현 호출 (트래커 바운드 및 기본 렌더링)
    super.onPaint(canvas);
    
    // segmentRect가 없으면 아무것도 그리지 않음
    if (segmentRect == null) return;
    
    // 이미 그려진 컬럼 위에 그라디언트로 다시 그리기
    final Paint gradientPaint = Paint()..style = PaintingStyle.fill;
    final double y = series.dataSource![currentSegmentIndex].y;
    final Rect rect = segmentRect!.outerRect;
    

    // 값에 따라 다른 border radius 적용
    final RRect customRRect = y >= 0
        ? RRect.fromRectAndCorners(
            rect,
            topLeft: Radius.circular(4),
            topRight: Radius.circular(4),
          )
        : RRect.fromRectAndCorners(
            rect,
            bottomLeft: Radius.circular(4),
            bottomRight: Radius.circular(4),
          );

    gradientPaint.shader = ui.Gradient.linear(
      rect.topCenter,
      rect.bottomCenter,
      y >= 0 
          ? [MyColors.blue1, MyColors.gray3]
          : [MyColors.blue2, MyColos.gray3],
      [0.0, 1.0],
    );
    
    // 동일한 segmentRect를 그라디언트로 다시 그림
    canvas.drawRRect(segmentRect!, gradientPaint);
}

문제 3. 애니메이션 효과 복원하기

  • 여기까지 왔다면 애니메이션 효과가 사라진 것을 확인할 수 있음
  • onPaint 메서드가 내부적으로 애니메이션까지 처리하는데 덮어쓰기했기 때문에 사라진 것임
  • 애니메이션을 처리할 때 사용하는 _oldSegmentRect라는 프라이빗 멤버에 접근할 수 없기 때문에 _customSegementRect_customOldSegementRect를 만들어서 활용
최종 코드
class BipolarGradientColumnRenderer
    extends ColumnSeriesRenderer<ChartData, DateTime> {
  final List<Color> colors;
  final List<double> steps;
  final Radius radius;

  BipolarGradientColumnRenderer({
    this.colors = const [MyColors.blue1, MyColors.gray3],
    this.steps = const [0.0, 1.0],
    this.radius = const Radius.circular(4),
  });

  @override
  ColumnSegment<DateTimeChartData, DateTime> createSegment() {
    return _ColumnCustomPainter(
      colors: colors,
      steps: steps,
      radius: radius,
    );
  }
}

class _ColumnCustomPainter extends ColumnSegment<ChartData, DateTime> {
  _ColumnCustomPainter({
    required this.colors,
    required this.steps,
    required this.radius,
  });

  final List<Color> colors;
  final List<double> steps;
  final Radius radius;
  RRect? _customOldSegmentRect;
  RRect? _customSegmentRect;

  /*
    onPaint를 override하면서 애니메이션도 적용하기 위한 코드
    라이브러리가 _segmentRect를 _oldSegmentRect에 저장하는 것처럼
    애니메이션을 적용하기 커스텀하게 만든 _cusomSegmentRect를 _customOldSegmentRect에 저장
  */
  @override
  void copyOldSegmentValues(
    double seriesAnimationFactor,
    double segmentAnimationFactor,
  ) {
    super.copyOldSegmentValues(seriesAnimationFactor, segmentAnimationFactor);
    _customOldSegmentRect = _customSegmentRect;
  }

  /* 
    onPaint를 override하여 커스텀 페인팅을 적용하기 위한 코드
    기존 로직을 최대한 모방, 프라이빗 멤버나 헬퍼에 접근할 수 없다면 일부 코드는 직접 작성해야 함
  */
  @override
  void onPaint(Canvas canvas) {
    if (series.isTrackVisible) {
      super.onPaint(canvas);
    }

    if (segmentRect == null) return;

    final double y = series.dataSource![currentSegmentIndex].y.toDouble();
    final Rect rect = segmentRect!.outerRect;
    final bool isPositive = y >= 0;

    // border radius 방향
    _customSegmentRect = isPositive
        ? RRect.fromRectAndCorners(
            rect,
            topLeft: radius,
            topRight: radius,
          )
        : RRect.fromRectAndCorners(
            rect,
            bottomLeft: radius,
            bottomRight: radius,
          );

    // animation 적용
    RRect? paintRRect;
    if (series.parent!.isLegendToggled &&
        _customOldSegmentRect != null &&
        series.animationType != AnimationType.loading) {
      paintRRect = RRect.lerp(
          _customOldSegmentRect, _customSegmentRect, animationFactor);
    } else {
      _customOldSegmentRect ??= isPositive
          ? RRect.fromRectAndCorners(
              Rect.fromLTWH(rect.left, rect.bottom, rect.width, 0),
              topLeft: radius,
              topRight: radius,
            )
          : RRect.fromRectAndCorners(
              Rect.fromLTWH(rect.left, rect.top, rect.width, 0),
              bottomLeft: radius,
              bottomRight: radius,
            );
      paintRRect = RRect.lerp(
          _customOldSegmentRect, _customSegmentRect, animationFactor);
    }

    if (paintRRect == null || paintRRect.isEmpty) {
      return;
    }

    // gradient 적용
    final Paint gradientPaint = Paint()..style = PaintingStyle.fill;
    gradientPaint.shader = Gradient.linear(
      isPositive
          ? paintRRect.outerRect.topCenter
          : paintRRect.outerRect.bottomCenter,
      isPositive
          ? paintRRect.outerRect.bottomCenter
          : paintRRect.outerRect.topCenter,
      colors,
      steps,
    );

    canvas.drawRRect(paintRRect, gradientPaint);
  }
}
그리고 이것저것 주입해서 쓰도록 함

문제 4. 다중 커스텀 렌더렁 사용할 때 발생하는 문제 해결

  • 필터에 따라 하나의 tick에서 하나 이상의 차트를 보여줄 때 커스텀 렌더러가 이상하게 동작하는 이슈가 있었음
  • 특정 패턴을 발견했는데 A - B - C - D 순으로 선언된 Column에서 모두 커스텀 렌더러가 사용될 때, 자신보다 나중에 선언된 차트보다 화면에 늦게 그려지는 경우(뒤늦게 필터에서 선택된 경우)에는 먼저 그려진 차트의 색상으로 적용되는 현상
  • 처음에는 설마 싱글턴인가 싶었는데 아무리봐도 그건 아니었음
  • 꽤 오랜 시간 헤매다 우연치 않게 ColumnSeries에 key 속성을 전달하면서 해결됨
  • 여전히 명확한 이유는 모르겠음. 아마도 Flutter의 렌더링에 관한 특성 때문이 아닌가 싶지만 더 조사 필요
ColumnSeries<ChartData, DateTime>(
  key: ValueKey(id),
  name: name,
  dataSource: dataSource,
  xValueMapper: (ChartData data, _) => data.x,
  yValueMapper: (ChartData data, _) => data.y,
  onCreateRenderer: (series) {
    return BipolarGradientColumnRenderer(
      colors: colors,
    );
  },
  spacing: 0.1,
  initialIsVisible: initialIsVisible,
  onRendererCreated: onRendererCreated,
);

문제 5. 차트 순서 문제 해결하기

  • 한 tick에서 커스텀 필터로 여러 Column을 보여줄 때 필터의 적용 여부에 따라 컬럼의 순서가 바뀜
  • 순서가 보장되는 것이 요구사항
  • build()안에서 조건문을 사용하는 기존 방식으로는 순서 보장이 안되는 이슈가 있었음

이 문제는 공식 문서의 onRendererCreated에 대한 내용을 보고 힌트를 얻었는데

  • 이 메서드는 시리즈 렌더러가 생성될 때 호출됨
  • 콜백에서 전달하는 controller를 인스턴스를 얻어서 각 시리즈의 isVisible 속성을 변경할 수 있음
  • 이 예제는 initialIsVisible에 관한 문서에서 확인 가능

참고