- 주요 기능
- 빠른 시작
- 프로젝트 구조
- 계층 구조
- 핵심 컴포넌트 상세
- UI 컴포넌트
- 상태 관리
- 개념 정리(좌표계/Position/Pivot)
- 입력 규칙 및 폴백
- 동작 원리 흐름도
- MVI 아키텍처
- 실시간 좌표 변환: Position, Rotation, Pivot 파라미터 입력
- 시각적 그리드 시스템: −250 ~ 250 범위의 좌표계 (50단위 그리드)
- 정점 좌표 계산: 변환 후 사각형 네 꼭짓점의 정확한 좌표 표시
- 피벗 기반 회전: 임의의 피벗 점을 중심으로 회전
flowchart TD
A[app/] --> A1[src/]
A1 --> A2[main/]
A2 --> AK[kotlin/]
A2 --> AR[res/]
%% Kotlin 소스 트리
AK --> C1[com/]
C1 --> C2[tedd/]
C2 --> ROOT[com.tedd.tedd_transform/]
%% 루트 파일 및 디렉터리
ROOT --> M0[MainActivity.kt]
ROOT --> CONST[constants/]
ROOT --> EXT[extensions/]
ROOT --> MODELS[models/]
ROOT --> UI[ui/]
ROOT --> UTILS[utils/]
%% constants
CONST --> CONST_F[DrawingConstants.kt]
%% extensions
EXT --> EXT_F[OffsetExtensions.kt]
%% models
MODELS --> MODELS_F[TransformModels.kt]
%% ui
UI --> UI_MAIN[TransformControllerScreen.kt]
UI --> UI_STATE[state/]
UI --> UI_CANVAS[canvas/]
UI --> UI_COMP[components/]
UI --> UI_THEME[theme/]
UI_STATE --> UI_S1[RememberTransformControllerState.kt]
UI_CANVAS --> UI_V1[CanvasDrawing.kt]
UI_COMP --> UI_C1[DrawingArea.kt]
UI_COMP --> UI_C2[TransformControls.kt]
UI_COMP --> UI_C3[VertexCoordinatesDisplay.kt]
UI_THEME --> UI_T1[Color.kt]
UI_THEME --> UI_T2[Theme.kt]
UI_THEME --> UI_T3[Type.kt]
%% utils
UTILS --> U1[CoordinateSystem.kt]
UTILS --> U2[TransformCalculator.kt]
%% res 하위 (요약)
AR --> RV[values/]
RV --> RV1[strings.xml]
RV --> RV2[colors.xml]
RV --> RV3[themes.xml]
graph TD
A[MainActivity.kt<br/>앱 진입점] --> B[TransformControllerScreen.kt<br/>View]
B --> C[DrawingArea<br/>Canvas 렌더링]
B --> D[VertexCoordinatesDisplay<br/>좌표 표시]
B --> E[TransformControls<br/>입력 컨트롤]
%% MVI 흐름
E -- onChange/onClick --> I[TransformationIntent]
I --> R[Reducer<br/>TransformIntent.intent]
R --> S["State<br/>TransformControllerStateHolder<br/>rememberTransformControllerState"]
S --> B
%% 비즈니스 로직/모델 계층
C --> F[Utils & Models<br/>비즈니스 로직]
D --> F
F --> G[CoordinateSystem]
F --> H[TransformCalculator]
F --> M[TransformModels]
역할: Global 좌표계와 Android Canvas 좌표계 간 변환
class CoordinateSystem(canvasWidth, canvasHeight) {
// Global 좌표 → Canvas 좌표
fun toCanvasX(globalX: Float): Float
fun toCanvasY(globalY: Float): Float
fun toCanvas(global: Offset): Offset
}-
입력 파라미터
canvasWidth,canvasHeight: ComposeCanvas의 실제 픽셀 크기.DrawScope.size에서 전달됩니다.- 그리드 가이드 범위:
Grid.MIN = -250f,Grid.MAX = 250f→coordRange = MAX - MIN = 500f.
-
스케일 계산
scale = min(canvasWidth, canvasHeight) / coordRange- 짧은 변을 기준으로 스케일을 잡아 ±250 정사각형 가이드가 화면에 맞게 들어갑니다.
- 화면 비율에 따라 상/하 또는 좌/우에 여백이 생길 수 있습니다.
-
좌표 매핑 공식 (Global → Canvas)
- X:
x_canvas = (x_global - MIN) * scale - Y:
y_canvas = canvasHeight - (y_global - MIN) * scale - 오프셋:
toCanvas(Offset(x, y)) = Offset(x_canvas, y_canvas)
- X:
-
의미/특징
- Global 좌표계는 X→오른쪽+, Y→위쪽+이며, 가이드 범위(−250..250) 내에서 원점(0,0)은 중앙에 위치합니다.
- 가이드 범위는 −250..250이며, 매핑 자체는 범위를 넘는 좌표에도 그대로 적용되어 그리드 밖으로 그려질 수 있습니다.
- 두 축에 동일한 스케일을 사용하므로 비율 왜곡이 없습니다(정사각형 유지).
-
예시
(MIN, MIN)=(-250, -250)→(0, canvasHeight)(0, 0)→(250 * scale, canvasHeight - 250 * scale)(MAX, MAX)=(250, 250)→(500 * scale, canvasHeight - 500 * scale)
좌표계 구분 (Local / Global / Canvas)
- Local 좌표계: 도형(사각형)의 내부 좌표계. 원점은 사각형의 Left-Bottom(좌하단), X→오른쪽+, Y→위쪽+. 초기 정점은
(0,0)=LeftBottom,(RECT_SIZE,0)=RightBottom,(RECT_SIZE,RECT_SIZE)=RightTop,(0,RECT_SIZE)=LeftTop기준으로 정의됩니다. - Global 좌표계: X→오른쪽+, Y→위쪽+. 가이드 범위는 −250..250이며, 원점(0,0)은 가이드의 중앙에 위치합니다.
- Canvas 좌표계: Android 픽셀 좌표. 좌상단이 원점, X→오른쪽+, Y→아래쪽+.
CoordinateSystem이 Global ⇄ Canvas 변환을 담당합니다.
역할: 회전 및 변환 행렬 계산
object TransformCalculator {
// 변환 후 정점 좌표 계산 (Global)
fun calculateVertices(state: TransformState): List<Vertex>
// 변환 후 정점 좌표 계산 (Canvas)
fun calculateTransformedVertices(
state: TransformState,
coordSystem: CoordinateSystem
): List<Offset>
}변환 순서:
- Local → Pivot 기준 회전: 피벗 점을 원점으로 이동 → 회전 → 복원
- Translation: 회전 결과를
position - pivot만큼 이동
회전 행렬:
x' = (x - pivotX) * cos(θ) - (y - pivotY) * sin(θ) + pivotX
y' = (x - pivotX) * sin(θ) + (y - pivotY) * cos(θ) + pivotY
-
입력값
state.pivot (p): Local 좌표계의 회전 중심점state.position (P): Pivot의 Global 좌표(빨간 점)state.rotation (θ): 도 단위 각도(반시계 +); 내부에서 라디안으로 변환하여cos/sin사용
-
로컬 사각형 정의(좌하단 원점)
- 정점 리스트
v0..v3는 아래 순서/위치로 생성됩니다.v0=(0, RECT_SIZE)→ LeftTop,v1=(RECT_SIZE, RECT_SIZE)→ RightTopv2=(RECT_SIZE, 0)→ RightBottom,v3=(0, 0)→ LeftBottom
- 각 정점에
VertexType을 동일한 순서로 매핑합니다.
- 정점 리스트
-
전역 좌표 계산식
- 각 로컬 정점
v에 대해:V_global = R(θ) · (v - p) + P- 여기서
R(θ)는 2×2 회전 행렬,p=state.pivot,P=state.position
- 구현 상 동일식:
rotated = rotateAround(v, p)→global = rotated - p + P
- 각 로컬 정점
-
메서드별 반환
calculateVertices(state): 위 공식을 통해 계산된 Global 좌표(수학적 Y 위로 +)를Vertex(type, x, y)로 반환calculateTransformedVertices(state, coordSystem): 위 Global 좌표를CoordinateSystem으로 Canvas 좌표(좌상단 원점, Y 아래로 +)로 변환하여Offset리스트 반환
-
각도/방향성
- θ가 양수이면 수학적 좌표계(우핸드, Y 위로 +) 기준 반시계(CCW) 회전입니다. 현재 Global이 Y 위로 +이므로 직관적 CCW가 됩니다.
-
정밀도/성능
- 각 프레임마다
cos(θ), sin(θ)를 1회만 계산하여 네 정점에 재사용합니다. RECT_SIZE와INTERVAL이 정수값이지만 계산은Float로 수행합니다(좌표 표시 시%.2f).
- 각 프레임마다
역할: Global 좌표를 Canvas 픽셀 좌표로 변환해 격자/축/도형/라벨/피벗을 그립니다. DrawingArea에서 순서대로 호출됩니다.
구성 함수와 동작
-
drawGrid(coordSystem)- 틱 순회:
tick = MIN..MAX를INTERVAL간격의 정수로 순회해 부동소수 오차 없이 0축을 판별합니다. - 축 판별:
isAxis = (tick == 0). - 렌더링: x=tick 수직선과 y=tick 수평선을 전 범위에 그립니다.
- 축(0): 검정(Black), 굵기
AXIS_STROKE_WIDTH - 일반: 회색(Gray), 굵기
GRID_STROKE_WIDTH
- 축(0): 검정(Black), 굵기
- 좌표 변환: 각 선의 시작/끝 좌표를
coordSystem.toCanvasX/Y(...)로 변환합니다.
- 틱 순회:
-
drawAxisLabels(coordSystem)- 기준축:
x=0,y=0의 캔버스 위치를 먼저 구해 라벨 기준선으로 사용합니다. - 라벨 범위: MIN..MAX를 INTERVAL 간격으로 순회하되 0은 제외(축 겹침 방지).
- 배치: X축 라벨은 아래쪽(바닥선) 근처, Y축 라벨은 왼쪽 축 근처에
LABEL_OFFSET_X/Y만큼 오프셋을 두고nativeCanvas.drawText로 그립니다.
- 기준축:
-
drawRectangle(state, coordSystem)- 정점 계산:
TransformCalculator.calculateTransformedVertices(state, coordSystem)로 Canvas 좌표의 네 꼭짓점을 구합니다. - 그리기:
Path로 네 점을 연결해 채우기(연한 회색 alpha 0.8) 후 외곽선(검정,RECT_STROKE_WIDTH)을 그립니다.
- 정점 계산:
-
drawPivotPoint(state, coordSystem)- 위치:
state.position(Pivot의 Global 좌표)을 Canvas로 변환 후 원을 그립니다. - 스타일: 빨간색(Red), 반지름
PIVOT_RADIUS픽셀.
- 위치:
호출 순서와 레이어링
DrawingArea의Canvas내부에서 다음 순서로 호출됩니다: 그리드 → 축 라벨 → 사각형 → 피벗 점.- 결과적으로 피벗 점이 가장 위에 올라와 항상 보입니다.
// 변환 상태
data class TransformState(
val position: Offset = Offset.Zero, // 이동
val rotation: Float = 0f, // 회전 각도 (도)
val pivot: Offset = Offset.Zero // 회전 중심점
)
// 정점 타입 (Sealed Class)
sealed class VertexType(@StringRes val stringResId: Int) {
data object LeftTop
data object RightTop
data object RightBottom
data object LeftBottom
}
// 정점 좌표
data class Vertex(
val type: VertexType,
val x: Float,
val y: Float
)- Grid Lines: 50단위 그리드 + 축 라인
- Axis Labels: 좌표 값 표시
- Rectangle: 변환된 사각형
- Pivot Point: 빨간 점으로 위치 표시
- 네 꼭짓점의 좌표를 실시간 표시- 형식:
Left-Top: (x, y)
- Position 입력: "x, y" 형식
- Rotation 입력: 각도 (도)
- Pivot 입력: "x, y" 형식
- Apply 버튼: 변환 적용
입력 버퍼(draft)와 적용 상태(applied)를 하나로 묶은 TransformControllerState를 사용합니다. Compose에서는 rememberTransformControllerState() 한 번만 호출하면 됩니다. 내부적으로 rememberSaveable + Saver를 통해 구성 변경 후에도 상태가 복원됩니다.
@Composable
fun rememberTransformControllerState(
initial: TransformControllerState = TransformControllerState()
): TransformControllerStateHolder
@Stable
class TransformControllerStateHolder internal constructor(
private val state: MutableState<TransformControllerState>
) {
val value: TransformState get() = state.value.applied
val draftValue: TransformDraft get() = state.value.draft
fun updateApplied(block: (TransformState) -> TransformState)
fun updateDraft(block: (TransformDraft) -> TransformDraft)
}
data class TransformControllerState(
val applied: TransformState = TransformState(),
val draft: TransformDraft = TransformDraft()
)
data class TransformDraft(
val position: String = "",
val rotation: String = "",
val pivot: String = ""
)val controller = rememberTransformControllerState()
val transformIntent = TransformIntent(controller)
DrawingArea(state = controller.value)
val vertices = TransformCalculator.calculateVertices(controller.value)
TransformControls(
position = controller.draftValue.position,
rotation = controller.draftValue.rotation,
pivot = controller.draftValue.pivot,
onPositionChange = { transformIntent.intent(TransformationIntent.InputPosition(it)) },
onRotationChange = { transformIntent.intent(TransformationIntent.InputRotation(it)) },
onPivotChange = { transformIntent.intent(TransformationIntent.InputPivot(it)) },
onApply = { transformIntent.intent(TransformationIntent.Apply) }
)- Saver 직렬화 포맷:
String[8] = [applied.pos.x, applied.pos.y, applied.rotation, applied.pivot.x, applied.pivot.y, draft.position, draft.rotation, draft.pivot] rememberSaveable로 구성 변경 및 시스템에 의한 프로세스 재생성(인스턴스 상태 복원 경로) 시 복원됩니다.
본 프로젝트의 상태 관리는 MVI(Model–View–Intent) 패턴을 따릅니다. 단방향 데이터 흐름으로 입력 → 인텐트 → 리듀서 → 상태 → 렌더링 순으로 진행됩니다.
- Intent:
TransformationIntent(입력 변화·Apply 등 사용자 의도) - Reducer:
TransformIntent.intent(...)(인텐트를 해석하여TransformControllerState에 반영) - State:
TransformControllerState(applied + draft)(UI가 구독하여 렌더) - View:
TransformControllerScreen+TransformControls(입력 이벤트를 Intent로 디스패치)
흐름도
flowchart LR
UI["View<br/>TransformControls"] -- onChange/onClick --> I["TransformationIntent"]
I --> R["Reducer<br/>TransformIntent.intent"]
R --> S["State<br/>TransformControllerState"]
S --> V["View<br/>Canvas/Vertex Display"]
핵심 규칙
- UI는 상태를 직접 수정하지 않고, 항상 Intent를 통해서만 상태가 변경됩니다.
- Apply 버튼을 누를 때만 draft가 applied로 커밋되어 도형/좌표가 갱신됩니다.
-
Position (
TransformState.position)- 의미: 피벗의 전역(Global) 좌표
- 표시: Canvas의 빨간 점으로 렌더링됨
- 용도: 회전이 끝난 도형을 Global 공간으로 평행 이동하는 기준
-
Pivot (
TransformState.pivot)- 의미: 도형(Local 좌표계)에서의 회전 중심점
- 용도: 각 정점을
pivot기준으로 회전
-
좌표계
- Local: 사각형 로컬 좌표. 좌하단 원점, X→오른쪽+, Y→위쪽+ (초기 정점 정의 기준)
- Global: X→오른쪽+, Y→위쪽+, 가이드 범위는 −250 ~ 250 (뷰포트 내에서 원점은 중앙)
- Canvas: Android 픽셀 좌표. 좌상단 원점, X→오른쪽+, Y→아래쪽+
- 변환:
TransformCalculator는 Local→Global 변환(회전/이동),CoordinateSystem은 Global→Canvas 변환 담당
-
Position, Pivot 입력
- 형식:
"x, y"(쉼표로 구분, 공백 허용, 소수 가능) - 예:
"0, 0","12.5, -30" - 파싱 실패 시:
Offset.Zero로 폴백됩니다. (경고/에러 UI 없음)
- 형식:
-
Rotation 입력
- 형식: 숫자(도 단위). 예:
"0","45","-30","12.5" - 파싱 실패 시:
0f로 폴백됩니다.
- 형식: 숫자(도 단위). 예:
-
적용 버튼
Apply클릭 시 현재 입력값이TransformState로 반영됩니다.
graph TD
U["사용자 입력<br/>- Position x,y<br/>- Rotation deg<br/>- Pivot x,y"] --> I["TransformationIntent<br/>(Input*)"]
I --> D["Draft 업데이트<br/>TransformControllerState.draft"]
A["Apply 버튼 클릭"] --> IA["TransformationIntent.Apply"]
IA --> R["Reducer<br/>TransformIntent.intent"]
R --> S["Applied 갱신<br/>TransformControllerState.applied"]
S --> C["TransformCalculator"]
C --> V1["초기 꼭짓점 생성"]
V1 --> V2["Pivot 기준 회전"]
V2 --> V3["Position 만큼 이동"]
V3 --> M["CoordinateSystem<br/>Global → Canvas 변환"]
M --> O["Canvas 렌더링<br/>Grid / Axis / Rectangle / Pivot"]
- Language: Kotlin
- UI Framework: Jetpack Compose
- Build System: Gradle (Kotlin DSL)
- Min SDK: 24 (Android 7.0)
- Target SDK: 36
dependencies {
// Jetpack Compose
implementation("androidx.compose.ui")
implementation("androidx.compose.material3")
implementation("androidx.activity.compose")
// Lifecycle
implementation("androidx.lifecycle.runtime.ktx")
}graph TD
U1["Transform Controller 화면"] --> U2["Drawing Area (Canvas)<br/>- Grid<br/>- Axis<br/>- Rectangle<br/>- Pivot"]
U1 --> U3["Vertex Coordinates<br/>LT / RT / RB / LB 좌표 표시"]
U1 --> U4["Controls<br/>Position x,y / Rotation deg / Pivot x,y / Apply"]
-
기본
- Position:
0, 0, Pivot:0, 0, Rotation:0 - 기대: 사각형이 원점 기준으로 축과 정렬됨. 빨간 점은 중앙.
- Position:
-
회전만
- Position:
0, 0, Pivot:0, 0, Rotation:45 - 기대: 원점 기준 45° 회전. 정점 좌표는 대칭적이고 좌표 표시가 ±값으로 변함.
- Position:
-
Pivot 이동 + 회전
- Position:
0, 0, Pivot:10, 10, Rotation:30 - 기대: 도형이 Local(10,10)을 중심으로 회전한 후 Global 원점에 위치. 빨간 점은 중심(0,0).
- Position:
-
Global 이동
- Position:
100, 50, Pivot:0, 0, Rotation:0 - 기대: 도형이 우측/상단으로 평행 이동. 빨간 점은 (100,50).
- Position:
-
혼합
- Position:
100, 50, Pivot:10, 10, Rotation:30 - 기대: Local(10,10) 기준 회전 → Global(100,50)으로 이동. 좌표 표시와 Canvas 렌더링 일치.
- Position: