Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,24 +122,29 @@ Xcode 버전에 따라 생성 결과가 달라지므로 로컬 `make` 결과를

시맨틱 버저닝(SemVer) 준수. Breaking change는 메이저 업데이트 시에만 반영. 메이저 업데이트 기간이 아닌 경우 `@available(*, deprecated)` 처리 후 메이저 업데이트 때 제거한다.

화면이 달라지는 변경은 마이너에서도 반영할 수 있다. 대신 마이그레이션 가이드에 기록한다.

### Breaking change 판정

다음 중 하나에 해당하면 breaking change다.
다음 중 하나에 해당하면 breaking change다. 사용처의 컴파일이 깨지는 변경이다.

- `public` 선언의 제거·이름 변경·시그니처 변경(파라미터 추가·삭제, 인자 라벨 변경, 반환 타입 변경)
- `@available(*, deprecated)` 선언 제거
- `enum` case 제거, 컴포넌트 옵션(variant·size 등) 제거
- **API는 그대로인데 렌더 결과가 달라지는 변경** - radius·타이포·패딩·아이콘 크기·색 토큰 값 조정. 컴파일 에러가 나지 않아 사용처가 가장 놓치기 쉬운 유형이다

기본값만 바꾸는 것도 렌더 결과가 달라지면 breaking change로 취급한다.
### 화면이 달라지는 변경 판정

API는 그대로인데 렌더 결과나 동작이 달라지는 변경이다. radius·타이포·패딩·아이콘 크기·색 토큰 값 조정, 기본값 변경, 인터랙션 변화가 해당한다. 컴파일 에러가 나지 않아 사용처가 가장 놓치기 쉬운 유형이다.

마이너에 넣을 수 있지만, 전역 토큰 값 조정처럼 **앱 전체 화면이 한꺼번에 달라지는 변경은 메이저에서만** 반영한다. 사용처는 SPM `upToNextMajor`로 마이너를 자동으로 따라 올라가므로, 범위가 넓으면 확인할 틈 없이 화면이 바뀐다.

### 마이그레이션 가이드 갱신 (필수)

Breaking change가 들어가는 PR은 마이그레이션 가이드를 **같은 PR에서** 함께 갱신한다. 나중에 몰아서 쓰면 무엇이 왜 바뀌었는지가 남지 않는다.
Breaking change나 화면이 달라지는 변경이 들어가는 PR은 마이그레이션 가이드를 **같은 PR에서** 함께 갱신한다. 나중에 몰아서 쓰면 무엇이 왜 바뀌었는지가 남지 않는다. 화면이 달라지는 변경은 릴리즈 노트에도 따로 적는다.

가이드는 영문 [MIGRATION.md](./MIGRATION.md)와 한국어 [MIGRATION.ko.md](./MIGRATION.ko.md) 두 벌이고 **둘 다 갱신한다.** 한쪽만 고치면 두 문서가 조용히 어긋난다.

대상 메이저 버전 절(`## X.0`)에 항목을 추가하고 다음 세 가지를 채운다.
Breaking change는 대상 메이저 버전 절(`## X.0`)에, 마이너에 들어가는 화면 변경은 그 마이너 버전 절(`## X.Y`)에 항목을 추가하고 다음 세 가지를 채운다.

| 항목 | 내용 |
|---|---|
Expand Down
39 changes: 37 additions & 2 deletions MIGRATION.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,49 @@

[English](./MIGRATION.md) | [한국어](./MIGRATION.ko.md)

메이저 버전 업그레이드에 필요한 변경 사항을 정리합니다. 최신 버전이 위에 옵니다.
메이저 버전 업그레이드에 필요한 변경 사항과, 마이너 버전에서 화면이 달라지는 변경을 정리합니다. 최신 버전이 위에 옵니다.

**이 문서는 브레이킹 체인지만 다룹니다.** 새로 생긴 컴포넌트와 모디파이어는 [추가된 API](#추가된-api)에 이름만 모아뒀고, 자세한 내용은 [릴리즈 노트](https://github.com/wanteddev/montage-ios/releases)에 있습니다.
**이 문서는 브레이킹 체인지와 화면이 달라지는 변경만 다룹니다.** 마이너 버전 절(`## X.Y`)은 컴파일은 통과하지만 화면이나 동작이 달라지는 항목이라, 업데이트한 뒤 해당 화면을 확인해주세요. 새로 생긴 컴포넌트와 모디파이어는 [추가된 API](#추가된-api)에 이름만 모아뒀고, 자세한 내용은 [릴리즈 노트](https://github.com/wanteddev/montage-ios/releases)에 있습니다.

각 메이저 절의 기준선은 **직전 메이저의 마지막 릴리즈 태그**입니다. 개발 중에 생겼다가 없어진 API는 이전 버전 사용처가 겪을 일이 없으므로 적지 않습니다.

---

## 4.3

**기준선: v4.2.2 → 4.3.0**

### 당겨서 새로고침

**성격: 화면 확인**

SwiftUI 당겨서 새로고침이 UIKit용 `PullToRefreshControl`과 같은 구현으로 바뀝니다. `ScrollView.onRefresh(_:)`는 코드를 고칠 필요가 없습니다.

| 항목 | 4.2 | 4.3 |
|---|---|---|
| iOS 16·17 | 시스템 스피너 | **Montage 로고** |
| 갱신 중 멈추는 높이 | 72pt | **60pt** (UIKit 화면과 같음) |
| 트리거 거리 | 당긴 거리 144pt | `UIRefreshControl` 시스템 기준 |
| 갱신 중 다시 올려 취소 | 됨 | **없음.** 갱신이 끝날 때까지 로고가 돕니다 |
| 시스템 내비게이션 라지 타이틀 아래 | - | 당긴 거리를 타이틀이 먼저 써서 **조금 당겼을 때 로고가 그려지지 않고**, 로고가 타이틀 위에 나옵니다 (`UIRefreshControl` 기본 동작). `.navigationBarTitleDisplayMode(.inline)`이면 다른 화면과 같습니다 |

`pullToRefresh(scrollYOffset:refresh:)`는 `scrollYOffset`을 쓰지 않게 되어 deprecated입니다. `pullToRefresh(refresh:)`로 옮기세요. iOS 16부터 쓸 수 있습니다.

```swift
// 4.2
SwiftUI.ScrollView { content }
.onScrollGeometryChange(for: CGFloat.self) { -($0.contentOffset.y + $0.contentInsets.top) } action: { scrollYOffset = $1 }
.pullToRefresh(scrollYOffset: $scrollYOffset) { await load() }

// 4.3
SwiftUI.ScrollView { content }
.pullToRefresh { await load() }
```

**확인할 화면**: `grep -rn "\.onRefresh\|\.pullToRefresh(" --include=*.swift`로 찾은 화면에서 당겨서 새로고침을 해보세요. 갱신 중 다시 올려 취소하는 흐름에 기대던 화면이 있으면 대응이 없습니다.

---

## 4.0

**기준선: v3.15.2 → 4.0.0**
Expand Down
41 changes: 38 additions & 3 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,49 @@

[English](./MIGRATION.md) | [한국어](./MIGRATION.ko.md)

Changes required to move between major versions. Newest first.
Changes required to move between major versions, plus visual changes shipped in minor versions. Newest first.

**This document covers breaking changes only.** New components and modifiers are listed by name under [Added APIs](#added-apis); see the [release notes](https://github.com/wanteddev/montage-ios/releases) for details.
**This document covers breaking changes and visual changes only.** Minor-version sections (`## X.Y`) compile cleanly but change how a screen looks or behaves, so check the affected screens after updating. New components and modifiers are listed by name under [Added APIs](#added-apis); see the [release notes](https://github.com/wanteddev/montage-ios/releases) for details.

Each major section is written against the **last release tag of the previous major**. APIs that appeared and disappeared during development are not listed, since nobody upgrading from the previous release ever saw them.

---

## 4.3

**Baseline: v4.2.2 → 4.3.0**

### Pull to refresh

**Kind: Visual check**

SwiftUI pull to refresh now uses the same implementation as the UIKit `PullToRefreshControl`. `ScrollView.onRefresh(_:)` needs no code change.

| Item | 4.2 | 4.3 |
|---|---|---|
| iOS 16·17 | System spinner | **Montage logo** |
| Height held while refreshing | 72pt | **60pt** (same as UIKit screens) |
| Trigger distance | 144pt pulled | `UIRefreshControl` system threshold |
| Cancel by scrolling back up while refreshing | Yes | **No.** The logo spins until the refresh finishes |
| Under a system navigation large title | - | The title consumes the pull first, so **a short pull does not draw the logo**, and the logo appears above the title (default `UIRefreshControl` behavior). With `.navigationBarTitleDisplayMode(.inline)` it matches other screens |

`pullToRefresh(scrollYOffset:refresh:)` is deprecated because `scrollYOffset` is no longer used. Move to `pullToRefresh(refresh:)`, available from iOS 16.

```swift
// 4.2
SwiftUI.ScrollView { content }
.onScrollGeometryChange(for: CGFloat.self) { -($0.contentOffset.y + $0.contentInsets.top) } action: { scrollYOffset = $1 }
.pullToRefresh(scrollYOffset: $scrollYOffset) { await load() }

// 4.3
SwiftUI.ScrollView { content }
.pullToRefresh { await load() }
```

**Screens to check**: pull to refresh on every screen found by `grep -rn "\.onRefresh\|\.pullToRefresh(" --include=*.swift`. If a screen relied on cancelling a refresh by scrolling back up, there is no replacement.

---

## 4.0

**Baseline: v3.15.2 → 4.0.0**
Expand All @@ -21,7 +56,7 @@ Breaking changes fall into four groups. Working top to bottom clears compile err
| 1 | [Renamed](#1-renamed) | Mechanical | mostly `sed` |
| 2 | [Removed, needs rework](#2-removed-needs-rework) | Restructure | Move to the replacement API |
| 3 | [No replacement](#3-no-replacement) | Judgement call | You have to choose |
| 4 | [Visual changes](#4-visual-changes) | Eyeball it | **No compile errors** |
| 4 | [Visual changes](#4-visual-changes) | Visual check | **No compile errors** |

**Do not skip section 4.** Those entries keep the same API and change only values, so the build passes while the screen changes. A clean build does not mean the migration is done.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Montage

struct ComponentListView: View {
@StateObject private var coordinator = ComponentListNavigationCoordinator()
@State var searchText: String = ""
@State private var searchText: String = ""
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
/// 세로 방향 size class. iPhone에서 portrait=`.regular`, landscape=`.compact`로 회전 시 토글되며,
/// 측정 지오메트리가 아니라 UIKit trait이라 회전 직후에도 stale되지 않고 갱신된다.
Expand Down Expand Up @@ -69,6 +69,9 @@ struct ComponentListView: View {
.navigationDestination(for: Component.self) { componentType in
coordinator.destinationView(for: componentType)
.navigationTitle(componentType.displayName)
// 기본값이면 iOS 17에서 라지 타이틀로 나와, 당겨서 새로고침의 당긴 거리를 타이틀이 먼저 써 버린다.
// OS마다 쇼케이스 화면이 달라지지 않게 인라인으로 고정한다.
.navigationBarTitleDisplayMode(.inline)
// Dynamic Type 변경이나 기기 회전(landscape↔portrait)을 런타임에 수행하면,
// SwiftUI가 커스텀 폰트 화면의 컨테이너 레이아웃(줄바꿈·너비 배분)을 다시 계산하지
// 않고 이전 캐시를 재사용해, 옛 너비 구조에 폰트만 커지거나 옛 너비가 유지되어
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,24 @@ import Montage
struct PullToRefreshPreview: View {
@State private var data: [Int] = Array(0...100)
@State private var seconds: String = "3"
@State private var containerIndex = 0
@FocusState private var inputFieldFocused: Bool

var body: some View {
PreviewLayout(mode: .floating) {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible())]) {
ForEach(data, id: \.self) { index in
HStack {
Spacer()
Text("row \(index)")
Spacer()
}
.padding()
.background(.blue)
}
if containerIndex == 0 {
ScrollView {
rows
}
.padding(.horizontal)
}
.onRefresh {
do {
print("refreshing...")
try await Task.sleep(nanoseconds: UInt64(1_000_000_000.0 * (Double(seconds) ?? 0)))
try Task.checkCancellation()
data = Array(0...100)
} catch {
print(error)
.onRefresh {
await refresh()
}
} else {
SwiftUI.ScrollView {
rows
}
.pullToRefresh {
await refresh()
}
}
} options: {
Expand All @@ -46,11 +38,38 @@ struct PullToRefreshPreview: View {
.focused($inputFieldFocused)
Text("초")
}
SegmentedIndexRow("container", index: $containerIndex, labels: ["ScrollView", "SwiftUI.ScrollView"])
}
.onTapGesture {
inputFieldFocused = false
}
}

private var rows: some View {
LazyVGrid(columns: [GridItem(.flexible())]) {
ForEach(data, id: \.self) { index in
HStack {
Spacer()
Text("row \(index)")
Spacer()
}
.padding()
.background(.blue)
}
}
.padding(.horizontal)
}

private func refresh() async {
do {
print("refreshing...")
try await Task.sleep(nanoseconds: UInt64(1_000_000_000.0 * (Double(seconds) ?? 0)))
try Task.checkCancellation()
data = Array(0...100)
} catch {
print(error)
}
}
}

#Preview {
Expand Down
Loading
Loading