cozy logo Moon
thumbnail thumbnail
profile
  • 개발

플러터 위젯 파헤치기 - 2: ClipPath, ClipOval, ClipRect, ClipRRect,

2023년 04월 27일 좋아요 0 댓글 0 조회수 481

플러터 위젯 파헤칠 목록

Row, Column, Flexible, Expanded, Spacer

Align, Center

ClipPath, ClipOval, ClipRect

Stack, Postioned

IndexedStack

Padding

Container, DocoratedBox, Contraint Box, Colored Box,

Layout Builder

Baseline

AspectRatio

Gestures Detector

OffState

Opacity

Overflow Bar

Overflow Box

Grid, Grid Tile, GridTileBar

Visibility

Wrap

Transform

 

들어가며

최근 자바스크립트로 플러터를 따라한 프레임워크를 만들고 있습니다. 원본을 완전히 베끼기엔 능력이 부족하지만..! 그래도 최선을 다해 베껴보려 합니다. 그 과정의 일환으로 플러터 위젯의 레이아웃 규칙을 글로 정리해 보겠습니다. 일주일마다 글 하나씩 쓰기 도전!

ClipPath

ClipPath는 clipper 인자를 받아, 화면을 자른다.

clipper의 getClip은 인자로 위젯의 size를 받는다.

아래 화면은 정사각형이 삼각형 모양으로 잘린 모습이다.

posting img
ClipPath 적용

아래는 ClipPath 위젯 코드이다.

clipBehavior는 none, hardEdge, antiAlias, antiAliasWithSaveLayer가 있다. none이면 Clipper가 영향을 끼치지 않는다.

hardEdge부터는 뒤로 갈 수록 더 또렷하게 그리는 듯 하다. (차이는 잘 모름 ㅎ)

Dart
class ClipPath extends SingleChildRenderObjectWidget { /// Creates a path clip. /// /// If [clipper] is null, the clip will be a rectangle that matches the layout /// size and location of the child. However, rather than use this default, /// consider using a [ClipRect], which can achieve the same effect more /// efficiently. /// /// The [clipBehavior] argument must not be null. If [clipBehavior] is /// [Clip.none], no clipping will be applied. const ClipPath({ super.key, this.clipper, this.clipBehavior = Clip.antiAlias, super.child, }) : assert(clipBehavior != null); /// If non-null, determines which clip to use. /// /// The default clip, which is used if this property is null, is the /// bounding box rectangle of the widget. [ClipRect] is a more /// efficient way of obtaining that effect. final CustomClipper<Path>? clipper; final Clip clipBehavior; RenderClipPath createRenderObject(BuildContext context) { return RenderClipPath(clipper: clipper, clipBehavior: clipBehavior); } } /// Different ways to clip a widget's content. enum Clip { none, hardEdge, antiAlias, antiAliasWithSaveLayer, }

Clip에 대한 주석

Dart
/// Different ways to clip a widget's content. enum Clip { /// No clip at all. /// /// This is the default option for most widgets: if the content does not /// overflow the widget boundary, don't pay any performance cost for clipping. /// /// If the content does overflow, please explicitly specify the following /// [Clip] options: /// * [hardEdge], which is the fastest clipping, but with lower fidelity. /// * [antiAlias], which is a little slower than [hardEdge], but with smoothed edges. /// * [antiAliasWithSaveLayer], which is much slower than [antiAlias], and should /// rarely be used. none, /// Clip, but do not apply anti-aliasing. /// /// This mode enables clipping, but curves and non-axis-aligned straight lines will be /// jagged as no effort is made to anti-alias. /// /// Faster than other clipping modes, but slower than [none]. /// /// This is a reasonable choice when clipping is needed, if the container is an axis- /// aligned rectangle or an axis-aligned rounded rectangle with very small corner radii. /// /// See also: /// /// * [antiAlias], which is more reasonable when clipping is needed and the shape is not /// an axis-aligned rectangle. hardEdge, /// Clip with anti-aliasing. /// /// This mode has anti-aliased clipping edges to achieve a smoother look. /// /// It' s much faster than [antiAliasWithSaveLayer], but slower than [hardEdge]. /// /// This will be the common case when dealing with circles and arcs. /// /// Different from [hardEdge] and [antiAliasWithSaveLayer], this clipping may have /// bleeding edge artifacts. /// (See https://fiddle.skia.org/c/21cb4c2b2515996b537f36e7819288ae for an example.) /// /// See also: /// /// * [hardEdge], which is a little faster, but with lower fidelity. /// * [antiAliasWithSaveLayer], which is much slower, but can avoid the /// bleeding edges if there's no other way. /// * [Paint.isAntiAlias], which is the anti-aliasing switch for general draw operations. antiAlias, /// Clip with anti-aliasing and saveLayer immediately following the clip. /// /// This mode not only clips with anti-aliasing, but also allocates an offscreen /// buffer. All subsequent paints are carried out on that buffer before finally /// being clipped and composited back. /// /// This is very slow. It has no bleeding edge artifacts (that [antiAlias] has) /// but it changes the semantics as an offscreen buffer is now introduced. /// (See https://github.com/flutter/flutter/issues/18057#issuecomment-394197336 /// for a difference between paint without saveLayer and paint with saveLayer.) /// /// This will be only rarely needed. One case where you might need this is if /// you have an image overlaid on a very different background color. In these /// cases, consider whether you can avoid overlaying multiple colors in one /// spot (e.g. by having the background color only present where the image is /// absent). If you can, [antiAlias] would be fine and much faster. /// /// See also: /// /// * [antiAlias], which is much faster, and has similar clipping results. antiAliasWithSaveLayer, }
painting.dart 1017 라인

보다시피 ClipPath은 RenderObjectWidget이다.

정의된 hitTest와 paint를 보면 clip이 메인값이란걸 알 수 있다.

(clip은 위에 보았던 clipper에서 getClip 으로 얻는다.)

Dart
class RenderClipPath extends _RenderCustomClip<Path> { RenderClipPath({ super.child, super.clipper, super.clipBehavior, }) : assert(clipBehavior != null); Path get _defaultClip => Path()..addRect(Offset.zero & size); void _updateClip() { _clip ??= _clipper?.getClip(size) ?? _defaultClip; } bool hitTest(BoxHitTestResult result, { required Offset position }) { if (_clipper != null) { _updateClip(); assert(_clip != null); if (!_clip!.contains(position)) { return false; } } return super.hitTest(result, position: position); } void paint(PaintingContext context, Offset offset) { if (child != null) { if (clipBehavior != Clip.none) { _updateClip(); layer = context.pushClipPath( needsCompositing, offset, Offset.zero & size, _clip!, super.paint, clipBehavior: clipBehavior, oldLayer: layer as ClipPathLayer?, ); } else { context.paintChild(child!, offset); layer = null; } } else { layer = null; } } }

ClipPath의 clip 타입은 Path이다. Path 코드를 보면 아래와 같다.

Dart
class Path extends NativeFieldWrapperClass1 { (...) void close() native 'Path_close'; void contains(Offset offset) native 'Path_contains'; void moveTo(double x, double y) native 'Path_moveTo'; void relativeMoveTo(double dx, double dy) native 'Path_relativeMoveTo'; void lineTo(double x, double y) native 'Path_lineTo'; void relativeLineTo(double dx, double dy) native 'Path_relativeLineTo'; void quadraticBezierTo(double x1, double y1, double x2, double y2) native 'Path_quadraticBezierTo'; void relativeQuadraticBezierTo(double x1, double y1, double x2, double y2) native 'Path_relativeQuadraticBezierTo'; void cubicTo(double x1, double y1, double x2, double y2, double x3, double y3) native 'Path_cubicTo'; void relativeCubicTo(double x1, double y1, double x2, double y2, double x3, double y3) native 'Path_relativeCubicTo'; void _arcToPoint(double arcEndX, double arcEndY, double radiusX, double radiusY, double rotation, bool largeArc, bool clockwise) native 'Path_arcToPoint'; (...) void _addRect(double left, double top, double right, double bottom) native 'Path_addRect'; void _addOval(double left, double top, double right, double bottom) native 'Path_addOval'; void _addRRect(Float32List rrect) native 'Path_addRRect'; (...) }
painting.dart 2325 라인

함수 구현부분은 플랫폼별로 빌드할 때 생성되는 듯 보인다.

아래에 addRect, addOval, addRRectmoveTo, lineTo, artToPoint, close의 조합으로 구현 가능해 보인다. 그러나 flutter는 이부분도 native 빌드할때 생성하는걸로 남겨두었다.

Path를 조작하는 법은 아래 블로그를 참고하시길

웹에서 canvas나 svg로 그릴 때랑 비슷한 인터페이스라 반가웠다ㅎㅎ

ClipRect

clipper의 generic type이 Path → Rect로 바뀐 것 외엔 ClipPath와 같다.

posting img

Rect의 코드는 아래와 같다.

fromLTRB를 기본으로, fromLTWH, fromCricle, fromCenter, fromPoints 를 통해 Rect를 만들 수 있다.

contains는 Path와 달리 native 빌드가 아니다.

Dart
class Rect { const Rect.fromLTRB(this.left, this.top, this.right, this.bottom) : assert(left != null), assert(top != null), assert(right != null), assert(bottom != null); const Rect.fromLTWH(double left, double top, double width, double height) : this.fromLTRB(left, top, left + width, top + height); Rect.fromCircle({ required Offset center, required double radius }) : this.fromCenter( center: center, width: radius * 2, height: radius * 2, ); Rect.fromCenter({ required Offset center, required double width, required double height }) : this.fromLTRB( center.dx - width / 2, center.dy - height / 2, center.dx + width / 2, center.dy + height / 2, ); Rect.fromPoints(Offset a, Offset b) : this.fromLTRB( math.min(a.dx, b.dx), math.min(a.dy, b.dy), math.max(a.dx, b.dx), math.max(a.dy, b.dy), ); bool contains(Offset offset) { return offset.dx >= left && offset.dx < right && offset.dy >= top && offset.dy < bottom; } }

ClipRect도 ClipPath와 마찬가지로 RenderObjectWidget이다.

Path에 addRect가 있길래 ClipPath를 활용할 줄 알았는데 flutter 코드를 보니 별개의 위젯으로 구현하였다.

hitTest와 paint 모두 Rect(clip)을 그대로 활용하는 모습을 볼 수 있다.

Dart
class RenderClipRect extends _RenderCustomClip<Rect> { Rect get _defaultClip => Offset.zero & size; bool hitTest(BoxHitTestResult result, { required Offset position }) { if (_clipper != null) { _updateClip(); assert(_clip != null); if (!_clip!.contains(position)) { return false; } } return super.hitTest(result, position: position); } void paint(PaintingContext context, Offset offset) { if (child != null) { if (clipBehavior != Clip.none) { _updateClip(); layer = context.pushClipRect( needsCompositing, offset, _clip!, super.paint, clipBehavior: clipBehavior, oldLayer: layer as ClipRectLayer?, ); } else { context.paintChild(child!, offset); layer = null; } } else { layer = null; } } }

ClipOval

ClipRect와 동일하다. 심지어 clipper 마저 같은 타입이다.

posting img

ClipOval 역시 RenderObjectWidget이다.

hitTest는 clip.contains를 호출하지 않고 별개로 구현하였고, paint는 _getClipPath 라는 함수를 통해 Path를 활용하여 구현하였다.

getClipPath를 쓸거면 ClipPath 위젯을 활용하면 되지 않았나 싶은데 따로 구현한걸보니 최적화와 관련이 있지 않나 싶다.

아래는 RenderClipOval 코드이다.

Dart
class RenderClipOval extends _RenderCustomClip<Rect> { RenderClipOval({ super.child, super.clipper, super.clipBehavior, }) : assert(clipBehavior != null); Rect? _cachedRect; late Path _cachedPath; Path _getClipPath(Rect rect) { if (rect != _cachedRect) { _cachedRect = rect; _cachedPath = Path()..addOval(_cachedRect!); } return _cachedPath; } Rect get _defaultClip => Offset.zero & size; bool hitTest(BoxHitTestResult result, { required Offset position }) { _updateClip(); assert(_clip != null); final Offset center = _clip!.center; // convert the position to an offset from the center of the unit circle final Offset offset = Offset( (position.dx - center.dx) / _clip!.width, (position.dy - center.dy) / _clip!.height, ); // check if the point is outside the unit circle if (offset.distanceSquared > 0.25) { // x^2 + y^2 > r^2 return false; } return super.hitTest(result, position: position); } void paint(PaintingContext context, Offset offset) { if (child != null) { if (clipBehavior != Clip.none) { _updateClip(); layer = context.pushClipPath( needsCompositing, offset, _clip!, _getClipPath(_clip!), super.paint, clipBehavior: clipBehavior, oldLayer: layer as ClipPathLayer?, ); } else { context.paintChild(child!, offset); layer = null; } } else { layer = null; } }

ClipRRect

아직입니다 ㅎ..

글을 마치며

위 코드 분석의 목적은 flutterjs를 만들때, ClipOval, ClipRect, ClipRRect를 플러터처럼 각각 별개의 RenderObjectWidget으로 만들어야 하나 아니면 ClipPath 를 재사용할까 가늠하기 위해서였다.

추측컨데 플러터가 저 3개의 위젯을 따로 구현한 이유는 아마 hitTesting과 관련한 최적화 때문이지 않을까 싶다. 저 셋다 Path의 contains를 활용안하는걸 보고 든 생각이다. 단순히 생각했을때 Path가 만들어내는 폐곡면이 hitTest의 인자로 오는 point(Offset)을 포함하는가를 판단하는 코드는,

RenderClipRectRenderClipOval에서 구현된 코드보다 훨씬 산술연산이 많이 들어갈 것 같다.

일단 ChatGPT는 이렇게 답했다.

posting img

여하튼! 나의 경우는 svg를 활용하기 때문에 hitTest를 따로 구현할 필요도 없고, <rect /> 와 <path /> 의 성능차이도 유의미하다고 생각하지 않기 때문에 ClipRect, CliptOval, ClipRRect 모두 ClipPath를 활용하여 구현하기로 결정했다.

아래는 flutterjs의 ClipPath 예시이다. clipper를 단순화 시켰다.

posting img

 

참고자료

 

관심 가질만한 포스트