한국의 개발자들을 위한 Google for Developers 국문 블로그입니다.
머티리얼 디자인을 위한 셰이프 시스템 빌드하기
2019년 1월 24일 목요일
<블로그 원문은
이곳
에서 확인하실 수 있습니다>
게시자: Yarden Eitan, 소프트웨어 엔지니어
Shape : 알고 보면 중요한 문제
Shape, 즉 어떤 모양이 없는 UI란 있을 수 없습니다. 카드, 버튼, 시트, 텍스트 입력란을 비롯하여 화면에서 보이는 다른 모든 것이 종종 일종의 '표면' 또는 '컨테이너' 내에 표시됩니다. 컴퓨팅 역사에서 대부분의 기간 동안 이러한 셰이프는 사각형을 의미했습니다. 사각형이 정말 많이 사용되었습니다.
하지만 머티리얼 팀은 모든 머티리얼 디자인 UI 컴포넌트에 걸쳐 체계적으로 독특한 모양을 적용하는 능력을 디자이너와 개발자에게 줄 수 있는 가능성이 있다는 점을 알았습니다. 모서리를 둥글게 처리할 수 있죠! 비스듬하게 잘라낸 모양도 있습니다! 디자이너 입장에서는 이를 통해 훨씬 더 나은 방식으로
주의를 끌고 브랜드를 표현하고 상호 작용을 지원하는
아름다운 인터페이스를 만들 수 있습니다. 개발자 입장에서는 모든 주요 플랫폼에서 일관된 모양 지원은 곧 앱에서 모양을 손쉽게 적용하고 맞춤설정할 수 있음을 뜻합니다.
엔지니어링 책임자로서 필자의 역할은 정말 흥미진진했습니다. 디자인 책임자와 협업하면서 프로젝트의 범위를 정하고 이처럼 복잡하고 새로운 시스템을 만들 최선의 방안을 찾아야 했습니다. (웹의 H1-H6 유형 계층 구조와 같은 분명한 구조와 이전의 구조를 가지거나 기본/보조 색이라는 아이디어를 가지는) 입력 체계와 색에 대해 잘 짜여진 시스템에 비하면, 셰이프는 서부 개척 시대처럼 거칠고 황량합니다. 셰이프는 아직도 정해야 할 규칙과 모범 사례가 많이 남아 있는, 비교적 미지의 영역입니다. 이러한 도전에 맞서기 위해, 필자는 온갖 다양한 머티리얼 디자인 엔지니어링 플랫폼을 사용해 발생 가능한 장애 요인을 파악하고 작업의 범위를 정하고 빌드해야 했습니다!
시스템을 확장할 때는 다음과 같은 두 가지 상위 수준의 목표가 있었습니다.
컴포넌트에 대한 셰이프 지원을 추가하여 버튼, 카드, 칩, 시트 등의 모양을 맞춤설정하는 능력을 개발자에게 부여함
셰이프를 사용하여 컴포넌트의 테마를 설정하는 좋은 방법을 정하고 개발하여 개발자가 일단 제품의 셰이프 스토리를 정하고 나면 각 컴포넌트를 개별적으로 맞춤설정할 필요 없이 앱을 통해 스토리를 계단식으로 전개할 수 있도록 함
엔지니어링 관점에서, 셰이프 지원을 추가하는 데는 많은 작업이 필요하고 복잡한 문제가 많았던 반면에 테마 설정은 디자인이 더욱 중심이 되는 과제였습니다. 이 글에서 필자는 엔지니어링 작업과 우리가 컴포넌트에 셰이프 지원을 추가한 방식에 주로 초점을 맞추겠습니다.
다음은 필자가 이 글에서 다룰 내용을 요약한 것입니다.
셰이프 지원 기능 살펴보기
플랫폼 전체에 셰이프 지원을 일관되게 빌드하기는 어려움
iOS에 셰이프 지원 구현
셰이프 코어 구현
컴포넌트를 위한 셰이프 지원 추가
컴포넌트에 맞춤 셰이프 적용
맺음말
셰이프 지원 기능 살펴보기
우리의 첫 작업은 다음 두 가지 질문을 면밀히 검토해보는 일이었습니다. 1) 셰이프 지원이란 무엇인가? 2) 셰이프 지원은 어떤 기능을 제공해야 하는가? 처음에는 우리의 목표가 다소 모호했습니다. 애초에는 가장자리와 모서리의 모양에 완벽한 유연성을 부여하여 가장자리와 모서리로 컴포넌트를 맞춤설정하는 API를 제안했습니다. 우리는 심지어 경로와 함께 맞춤 .png 파일을 받아 각각의 플랫폼에서 그 파일을 모양이 있는 컴포넌트로 변환하는 방법도 생각해보았습니다.
우리는 이내 아무런 제한도 두지 않으면 그러한 시스템을 정의하기가 극히 어려워진다는 사실을 깨달았습니다. 유연성이 더 많다고 해서 결과가 반드시 더 나은 것은 아닙니다. 예를 들어 뱀 모양의 FAB와 열차 모양의 카드를 만들 수 있게 해주는 유연하고 간편한 API를 정의하는 건 꽤나 대단한 업적이 될 겁니다. 하지만 그런 요소는 머티리얼 디자인 안내에서 추천하는 분명하고 간단한 접근 방식과는 거의 확실히 상반됩니다.
이 트럭 모양의 FAB는 머티리얼 디자인 안내에서는 명백히 '금지'되는 셰이프입니다.
우리는 우리가 제공할 수 있는 각 기능에 대한 부가가치 대비 시간과 리소스의 비용을 비교해야 했습니다.
이러한 개방형 질문에 답하기 위해 우리는 디자인, 엔지니어링 및 도구 제작을 담당하는 팀원을 포함하여 꼬박 일주일이 걸리는 워크숍을 실시하기로 했습니다. 이 워크숍은 극히 효과적인 것으로 입증되었습니다. 입력 데이터가 많았지만 우리는 어떤 기능이 현실성이 있고 사용자에게 가장 큰 영향력을 발휘할지 정리해나갈 수 있었습니다. 우리의 최종 제안은 초기 시스템이 정사각형, 둥근 모양, 잘린 모양의 세 가지 셰이프 유형을 지원하도록 하자는 것이었습니다. 세 가지 셰이프는 컴포넌트의 모서리를 맞춤설정하는 API를 통해 실현할 수 있습니다.
플랫폼 전체에 셰이프 지원을 일관되게 빌드
여러 플랫폼에 대한 빌드 작업을 해본 사람이라면
일관성이 관건
이라는 사실을 잘 압니다. 하지만 우리는 워크숍 중에 모든 플랫폼(Android, Flutter, iOS 및 웹)에 대해 정확히 같은 기능을 제공하는 것이 얼마나 어려운 일인지 절감했습니다. 최대의 장애 요인은 무엇이었을까요? 잘린 모서리 모양을 웹에 효과적으로 적용하는 일이었습니다.
뾰족하거나 둥근 모서리와는 달리, 잘린 모서리는 웹에서 기본 제공되는 네이티브 솔루션이 없습니다.
웹 팀에서 다양한 솔루션을 살펴보았고, 우린 심지어 모서리를 가려 잘린 것처럼 보이게 하려고 각 모서리 위에 배경색과 같은 정사각형을 추가하는 아이디어도 고려했습니다. 물론, 명백한 단점이 있습니다. 그림자가 가려지고 배경이 정적이지 않거나 색이 둘 이상일 때는 정사각형 자체가 카멜레온처럼 동작할 필요가 있다는 점입니다.
그래서 우리는 처음에는 실제로 작동할 훌륭한 솔루션처럼 보였던
폴리필
과 함께
Houdini(그리기 워크릿) API
를 조사했습니다. 하지만 이 지원을 추가하려면 추가적인 작업이 필요합니다.
우리의 UI 컴포넌트는 그림자를 사용해 높이를 표시하고 새로운 캔버스 그림자가 네이티브 CSS 상자 그림자와는 다르게 보이므로 시스템 전체를 통틀어 그림자를 다시 구현해야 할 것입니다.
우리의 UI 컴포넌트는 탭할 때 시각적 리플 효과도 표시하므로 다루기 어려움을 보여줍니다. 우리가 그리기 워크릿에서 리플을 계속 사용하려면 성능에 중대한 영향을 주지 않는 브라우저 간 마스킹 솔루션이 없으므로 리플을 다시 구현해야 할 것입니다.
더 많은 엔지니어링 작업을 추가하고 Houdini 경로를 따라 내려가기로 했다 하더라도 여전히 가치 대 비용의 문제가 남게 되며, 특히 웹 생태계 전체에서 Houdini가 여전히 '
준비되지 않은
' 상태로 남게 됩니다.
우리는 연구 결과를 기반으로 하고 노력이라는 비용의 무게를 감안하여 (최소한 현재로서는) 결국 웹 UI에 대해서는 잘린 모서리를 지원하지
않고
계속 진행하기로 했습니다. 하지만 좋은 소식은 우리가 요구 사항을 자세히 규정하고 빌드 작업을 시작할 수 있다는 점이었습니다!
iOS에 셰이프 지원 구현
기능 세트를 세심하게 가다듬은 후에 빌드 작업에 돌입하는 것은 각 플랫폼의 엔지니어에게 달린 문제였습니다. 필자는 iOS를 위한 셰이프 지원 빌드 작업을 도왔습니다. 우리가 작업한 방식을 소개합니다.
코어 구현
iOS에서는 사용자 인터페이스의 기본 구성 요소가 UIView 클래스의 인스턴스를 기반으로 합니다. 각각의 UIView는 CALayer 인스턴스의 지원을 받아 시각적 콘텐츠를 관리하고 표시합니다. CALayer의 속성을 수정하여 색, 테두리, 그림자뿐 아니라 기하학적 형상과 같은 시각적 외관의 다양한 속성을 수정할 수 있습니다.
우리가 CALayer의 기하학적 형상을 언급할 때는 항상 사각형 내에 있는 형상을 말하는 것입니다.
그 프레임은 위치의 경우 (x, y) 쌍, 크기의 경우 (너비, 높이) 쌍으로 빌드됩니다. 계층의 사각형 모양을 조작하기 위한 기본 API는 cornerRadius를 설정함으로써 반경 값을 받아 그 값만큼 둥글게 만들 네 모서리를 설정합니다. 사각형 지원과 둥근 모서리를 위한 간편한 API라는 개념은 Android, Flutter 및 웹에서는 전반적으로 꽤 많이 존재합니다. 하지만 잘린 모서리와 맞춤 가장자리와 같은 개념은 보통 그다지 간단하지 않습니다. 우리는 이러한 기능을 제공하기 위해 잘 정의된 특정한 셰이프 속성을 가진 CALayer를 만들기 위한 생성기를 제공하는 셰이프 라이브러리를 구축했습니다.
고맙게도, Apple은 CALayer를 하위 클래스로 분류하고 customPath 속성을 가진 CAShapeLayer 클래스를 제공합니다. 이 속성을 맞춤 CGPath에 지정하면 우리가 원하는 모양을 만들 수 있습니다.
그런 다음 우리는 경로 기능을 염두에 두고서 CGPath API를 활용하고 사용자가 컴포넌트의 모양을 만들 때 관심을 가질 속성을 제공하는 클래스를 빌드했습니다. 그 API는 다음과 같습니다.
/**
An MDCShapeGenerating for creating shaped rectangular CGPaths.
By default MDCRectangleShapeGenerator creates rectangular CGPaths.
Set the corner and edge treatments to shape parts of the generated path.
*/
@interface MDCRectangleShapeGenerator : NSObject <MDCShapeGenerating>
/**
The corner treatments to apply to each corner.
*/
@property(nonatomic, strong) MDCCornerTreatment *topLeftCorner;
@property(nonatomic, strong) MDCCornerTreatment *topRightCorner;
@property(nonatomic, strong) MDCCornerTreatment *bottomLeftCorner;
@property(nonatomic, strong) MDCCornerTreatment *bottomRightCorner;
/**
The offsets to apply to each corner.
*/
@property(nonatomic, assign) CGPoint topLeftCornerOffset;
@property(nonatomic, assign) CGPoint topRightCornerOffset;
@property(nonatomic, assign) CGPoint bottomLeftCornerOffset;
@property(nonatomic, assign) CGPoint bottomRightCornerOffset;
/**
The edge treatments to apply to each edge.
*/
@property(nonatomic, strong) MDCEdgeTreatment *topEdge;
@property(nonatomic, strong) MDCEdgeTreatment *rightEdge;
@property(nonatomic, strong) MDCEdgeTreatment *bottomEdge;
@property(nonatomic, strong) MDCEdgeTreatment *leftEdge;
/**
Convenience to set all corners to the same MDCCornerTreatment instance.
*/
- (void)setCorners:(MDCCornerTreatment *)cornerShape;
/**
Convenience to set all edge treatments to the same MDCEdgeTreatment instance.
*/
- (void)setEdges:(MDCEdgeTreatment *)edgeShape;
이와 같은 API를 제공함으로써, 사용자는 모서리 또는 가장자리만을 위한 경로를 생성할 수 있고 위의 MDCRectangleShapeGenerator 클래스는 그러한 속성을 감안하여 셰이프를 만들게 됩니다. 우리는 초기 셰이프 시스템의 이러한 초기 구현을 위해 모서리 속성만 사용했습니다.
보시다시피, 모서리 자체는 다음과 같이 중요한 정보 중 세 부분을 캡슐화하는 MDCCornerTreatment 클래스로 만들어집니다.
모서리의 값(각각의 특정 모서리 유형이 값을 받음)
제공되는 값이 표면 높이의 백분율 또는 절대값인지 여부
주어진 값과 모서리 유형을 기준으로 경로 생성기를 반환하는 메서드. 이 메서드는 모서리에 대해 셰이프의 전체 경로에 추가할 수 있는 올바른 경로를 받을 방법을 MDCRectangleShapeGenerator에 제공하게 됩니다.
작업을 훨씬 간단하게 처리하기 위해 우리는 사용자가 모서리 경로를 계산하여 맞춤 모서리를 빌드해야 하는 상황은 피하고 싶었으므로, 둥근 모서리, 곡선 모서리, 잘린 모서리를 생성하는 MDCCornerTreatment를 위한 3가지 편리한 하위 클래스를 제공했습니다.
한 예로서, 잘린 모서리 처리에서는 모서리의 가장자리에서 시작해 X축과 Y축으로 같은 거리만큼 이동하는 UI 점의 개수를 기준으로 자르기의 각도와 크기를 정의하는 'cut'이라는 값을 받습니다. 셰이프가 100x100 크기의 정사각형이고 MDCCutCornerTreatment를 사용하고 cut 값을 50으로 하여 모든 모서리를 설정한 경우, 최종 결과는 50x50 크기의 다이아몬드 모양이 될 것입니다.
다음은 잘린 모서리 처리가 경로 생성기를 구현하는 방법을 보여줍니다.
- (MDCPathGenerator *)pathGeneratorForCornerWithAngle:(CGFloat)angle
andCut:(CGFloat)cut {
MDCPathGenerator *path =
[MDCPathGenerator pathGeneratorWithStartPoint:CGPointMake(0, cut)];
[path addLineToPoint:CGPointMake(MDCSin(angle) * cut, MDCCos(angle) * cut)];
return path;
}
잘린 모서리의 경로는 자르기를 결정하는 2개의 점(모서리의 각 가장자리에 하나씩)에만 관심을 둡니다. 그 두 점은 바로 (0, cut)과 (sin(angle) * cut, cos(angle) * cut)입니다. 우리는 모서리가 90도인 사각형에 관해서만 얘기하는 것이므로, 이 사례에서 두 번째 점은 (cut, 0)에 해당합니다. sin(90) = 1이고 cos(90) = 0이기 때문입니다.
다음은 둥근 모서리 처리가 경로 생성기를 구현하는 방법을 보여줍니다.
- (MDCPathGenerator *)pathGeneratorForCornerWithAngle:(CGFloat)angle
andRadius:(CGFloat)radius {
MDCPathGenerator *path =
[MDCPathGenerator pathGeneratorWithStartPoint:CGPointMake(0, radius)];
[path addArcWithTangentPoint:CGPointZero
toPoint:CGPointMake(MDCSin(angle) * radius, MDCCos(angle) * radius)
radius:radius];
return path;
}
(0, radius)의 시작점부터 시작해서 자르기 예제와 유사하게 (radius, 0)으로 변환되는 점 (sin(angle) * radius, cos(angle) * radius)까지 원호를 그립니다. 마지막으로, 반경 값은 호의 반경입니다.
컴포넌트를 위한 셰이프 지원 추가
모서리와 가장자리를 설정하기에 편리한 API를 MDCRectangleShapeGenerator에 제공한 후, 셰이프 생성기를 받아 셰이프를 컴포넌트에 적용하기 위해 각 컴포넌트에 대한 속성을 추가할 필요가 있었습니다.
이제는 지원되는 각 컴포넌트가 MDCShapeGenerator를 받거나 pathForSize 메서드를 구현하는 다른 셰이프 생성기를 받을 수 있는 API에 shapeGenerator 속성을 가집니다. 컴포넌트의 너비와 높이가 주어지면 셰이프의 CGPath가 반환됩니다. 또한 생성되는 경로가 표시되기 위해 컴포넌트의 UIView의 기본 CALayer에 적용되도록 할 필요가 있었습니다.
우리는 컴포넌트에 셰이프 생성기의 경로를 적용함으로써 다음 몇 가지 사항을 명심해야 했습니다.
적절한 그림자, 테두리 및 배경색 지원 추가
그림자, 테두리 및 배경색은 기본 UIView API의 일부이고 맞춤 CALayer 경로를 반드시 고려하는 것은 아니므로(기본 사각형 경계를 따름), 우리는 추가 지원을 제공해야 했습니다. 그래서 MDCShapedShadowLayer가 뷰의 기본 CALayer가 되도록 구현했습니다. 이 클래스가 하는 일은 셰이프 생성기 경로를 받은 다음 그 경로가 계층의 그림자 경로가 되도록 전달하여 그림자가 맞춤 셰이프를 따르도록 하는 것입니다. 이 클래스는 또한 최상위 수준 UIView API를 호출하는 대신 맞춤 경로를 유지하는 CALayer에 대한 값을 명시적으로 설정함으로써 배경색과 테두리 색/너비를 설정하기 위한 다양한 API도 제공합니다. 한 예로서, 배경색을 검은색으로 설정할 때 (UIView의 backgroundColor를 호출하는 대신) CALayer의 fillColor를 호출합니다.
shadowPath 및 cornerRadius와 같은 계층의 속성 설정을 의식함
셰이프의 계층은 뷰의 기본 계층과는 다르게 설정되므로, 우리는 기존 컴포넌트 코드에서 계층의 속성을 설정하는 장소를 알아야 합니다. 한 예로서, 컴포넌트의 cornerRadius 설정(Apple의 API를 사용하여 둥근 모서리를 설정하는 기본 방법임)은 맞춤 셰이프도 설정하는 경우에는 실제로는 적용할 수 없는 방법이 됩니다.
터치 이벤트 지원
터치 수신 역시 뷰의 원래 사각형 경계에서만 적용됩니다. 맞춤 셰이프를 사용할 때는 계층이
그려지지 않는
사각형 경계 내에 장소가 있거나 계층이
그려지는
경계 외부에 장소가 있는 경우가 있을 것입니다. 그래서 우리는 셰이프가 있는 경우와 없는 경우에 해당하는 적절한 터치를 지원하고 그에 따라 적절히 동작할 방법이 필요했습니다.
이를 실현하기 위해 UIView의 hitTest 메서드를 재정의합니다. hitTest 메서드는 터치를 수신하기로 되어 있는 뷰를 반환하는 역할을 맡습니다. 우리의 사례에서는 터치 이벤트가 생성되는 셰이프 경로 내에 있는 경우 맞춤 셰이프의 뷰를 반환하도록 메서드를 다음과 같이 구현했습니다.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (self.layer.shapeGenerator) {
if (CGPathContainsPoint(self.layer.shapeLayer.path, nil, point, true)) {
return self;
} else {
return nil;
}
}
return [super hitTest:point withEvent:event];
}
잉크 리플 지원
다른 속성과 마찬가지로, (사용자에게 리플 효과를 터치 피드백으로 제공하는) 우리의 잉크 리플 역시 기본 사각형 경계 위에 빌드됩니다. 잉크의 경우 1) maxRippleRadius와 2) 경계에 대한 마스킹의 두 가지를 업데이트합니다. 셰이프가 경계보다 작거나 큰 경우에는 maxRippleRadius를 업데이트해야 합니다. 이런 경우에는 더 작은 셰이프의 경우 잉크가 너무 빠르게 리플하고 더 큰 셰이프의 경우 리플이 전체 셰이프를 커버하지 못하므로 경계에 의지할 수 없습니다. 맞춤 셰이프가 기본 경계보다 클 때는 잉크가 경계 외부로 퍼질 수 있도록 잉크 계층의 maskToBounds도 NO로 설정할 필요가 있습니다.
- (void)updateInkForShape {
CGRect boundingBox = CGPathGetBoundingBox(self.layer.shapeLayer.path);
self.inkView.maxRippleRadius =
(CGFloat)(MDCHypot(CGRectGetHeight(boundingBox), CGRectGetWidth(boundingBox)) / 2 + 10.f);
self.inkView.layer.masksToBounds = NO;
}
컴포넌트에 맞춤 셰이프 적용
모든 구현이 완료된 상태에서, 다음은 머티리얼 버튼 컴포넌트에 잘린 모서리를 제공하는 방법을 플랫폼별로 보여주는 예시입니다.
Android:
Kotlin
button.background as? MaterialShapeDrawable?.let {
it.shapeAppearanceModel.apply {
cornerFamily = CutCornerTreatment(cornerSize)
}
}
XML:
<com.google.android.material.button.MaterialButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:shapeAppearanceOverlay="@style/MyShapeAppearanceOverlay"/>
<style name="MyShapeAppearanceOverlay">
<item name="cornerFamily">cut</item>
<item name="cornerSize">4dp</item>
<style>
Flutter:
FlatButton(
shape: BeveledRectangleBorder(
// Despite referencing circles and radii, this means "make all corners 4.0".
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
iOS:
MDCButton *button = [[MDCButton alloc] init];
MDCRectangleShapeGenerator *rectShape = [[MDCRectangleShapeGenerator alloc] init];
[rectShape setCorners:[MDCCutCornerTreatment alloc] initWithCut:4]]];
button.shapeGenerator = rectShape;
웹(둥근 모서리):
.my-button {
@include mdc-button-shape-radius(4px);
}
맺음말
필자는 이 문제를 해결하여 그 해결책을 머티리얼 디자인 시스템에 포함시킨 점에 정말 짜릿한 흥분을 느낍니다. 특히 디자인 팀과 긴밀하게 협력하며 일한 점에 보람을 느낍니다. 필자는 엔지니어로서 다소 엇비슷한 각도로 문제에 접근하는 경향이 있는데, 문제에 대한 사고방식이 다른 엔지니어와 매우 흡사하기도 합니다. 하지만 디자이너와 함께 문제를 해결할 때는 완전히 직각의 시각에서 문제를 바라본다는 느낌인데(그냥 말장난임), 더 낫고 신중한 해결책인 것으로 드러날 때가 종종 있습니다.
우리는 계속해서 원활하게 머티리얼 셰이프 시스템을 성장시키고 가장자리 처리와 더욱 복잡한 셰이프와 같은 목표를 위해 훨씬 더 많은 지원을 제공하고 있습니다. (Houdini가 준비되는) 언젠가는 웹에서도 잘린 모서리를 지원할 수 있을 것입니다.
GitHub에서
Android
,
Flutter
,
iOS
,
Web
과 같은 다양한 플랫폼에 사용 가능한 코드를 확인해 보세요. 새로 업데이트한
셰이프 관련 머티리얼 디자인 안내
도 확인해 보세요.
Contents
ML/Tensorflow
Android
Flutter
Web/Chrome
Cloud
Google Play
Community
Game
Firebase
검색
Tag
인디게임페스티벌
정책 세미나
창구프로그램
AdMob
AI
Android
Android 12
Android 12L
Android 13
Android 14
Android Assistant
Android Auto
Android Games
Android Jetpack
Android Machine Learning
Android Privacy
Android Studio
Android TV
Android Wear
App Bundle
bootcamp
Business
Chrome
Cloud
Community
compose
Firebase
Flutter
Foldables
Game
gdg
GDSC
google
Google Developer Student Clubs
Google Play
Google Play Games
Interview
Jetpack
Jetpack Compose
kotlin
Large Screens
Library
ma
Material Design
Material You
ML/Tensorflow
mobile games
Now in Android
PC
Play Console
Policy
priva
wa
wear
Wearables
Web
Web/Chrome
Weeklyupdates
WorkManager
Archive
2024
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2023
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2022
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2021
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2020
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2019
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2018
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2017
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2016
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2015
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2014
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2013
12월
11월
10월
9월
8월
7월
6월
5월
4월
3월
2월
1월
2012
12월
11월
10월
9월
8월
7월
6월
5월
3월
2월
1월
2011
12월
11월
Feed