diff --git a/CLAUDE.md b/CLAUDE.md index ff93af77..b002f314 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`)에 항목을 추가하고 다음 세 가지를 채운다. | 항목 | 내용 | |---|---| diff --git a/MIGRATION.ko.md b/MIGRATION.ko.md index e2c1f63a..fa9c8783 100644 --- a/MIGRATION.ko.md +++ b/MIGRATION.ko.md @@ -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** diff --git a/MIGRATION.md b/MIGRATION.md index f69237d7..1fb33c67 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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** @@ -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. diff --git a/Sources/Blueprint/Sources/Scene/ComponentList/ComponentListView.swift b/Sources/Blueprint/Sources/Scene/ComponentList/ComponentListView.swift index 1c9165d2..9cd4fdb4 100644 --- a/Sources/Blueprint/Sources/Scene/ComponentList/ComponentListView.swift +++ b/Sources/Blueprint/Sources/Scene/ComponentList/ComponentListView.swift @@ -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되지 않고 갱신된다. @@ -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가 커스텀 폰트 화면의 컨테이너 레이아웃(줄바꿈·너비 배분)을 다시 계산하지 // 않고 이전 캐시를 재사용해, 옛 너비 구조에 폰트만 커지거나 옛 너비가 유지되어 diff --git a/Sources/Blueprint/Sources/Scene/Previews/PullToRefreshPreview.swift b/Sources/Blueprint/Sources/Scene/Previews/PullToRefreshPreview.swift index 1ad34a0e..2e891ba6 100644 --- a/Sources/Blueprint/Sources/Scene/Previews/PullToRefreshPreview.swift +++ b/Sources/Blueprint/Sources/Scene/Previews/PullToRefreshPreview.swift @@ -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: { @@ -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 { diff --git a/Sources/Montage/1 Components/9 Utilities/PullToRefresh.swift b/Sources/Montage/1 Components/9 Utilities/PullToRefresh.swift index baf6472e..7fbc0545 100644 --- a/Sources/Montage/1 Components/9 Utilities/PullToRefresh.swift +++ b/Sources/Montage/1 Components/9 Utilities/PullToRefresh.swift @@ -5,312 +5,187 @@ // Created by 김삼열 on 12/18/24. // -import Lottie import SwiftUI +import UIKit -@available(iOS 18, *) +/// SwiftUI 스크롤 뷰에 ``PullToRefreshControl``을 붙이는 당겨서 새로고침입니다. +/// +/// SwiftUI `ScrollView` 밑에 깔린 `UIScrollView`를 찾아 `refreshControl`에 ``PullToRefreshControl``을 붙입니다. +/// UIKit 화면과 같은 컨트롤을 쓰므로 로고 모션과 트리거 거리, 갱신 중 높이가 같습니다. public enum PullToRefresh { struct Modifier: ViewModifier { - @Binding private var scrollYOffset: CGFloat - private let refresh: () async -> Void + let refresh: () async -> Void - init(scrollYOffset: Binding, refresh: @escaping () async -> Void) { - _scrollYOffset = scrollYOffset - self.refresh = refresh + func body(content: Content) -> some View { + content.background(Installer(refresh: refresh)) } + } - @State private var phase: AnimatingLogo.Phase = .idle - @State private var task: Task? - - @State private var isScrolling = false - // 리프레시를 트리거링 할 가능성이 있는 '트리거스크롤'이 진행중임을 의미하는 플래그 - @State private var isTriggerScrolling = false - // 스크롤 옵셋을 임의로 조정하기 위한 '투명뷰'의 필요 여부를 나타내는 플래그 - @State private var isClearRectNeeded = false - - // 로고 자체 높이(최초 셋팅 이후 고정값) - @State private var logoHeight: CGFloat = .zero - // 애니메이션이 시작되는 스크롤 옵셋 쓰레숄드(최초 셋팅 이후 고정값) - private var animationThreshold: CGFloat { logoHeight / 2 } - // 리프레시가 트리거되는 스크롤 옵셋 쓰레숄드(최초 셋팅 이후 고정값) - private var refreshThreshold: CGFloat { logoHeight * 4 } - // 리프레시 중에 스크롤이 멈춰있는 높이 - private var hangingHeight: CGFloat { logoHeight * 2 } - - // 로고와 패딩을 포함한 높이(최대: thresholdToRefresh) - @State private var pullToRefreshViewHeight: CGFloat = .zero - // 투명뷰 높이 - @State private var clearRectHeight: CGFloat = .zero + /// 수정자를 붙인 뷰의 배경에 깔려 그 영역의 `UIScrollView`를 찾고 컨트롤을 붙인다. + private struct Installer: UIViewRepresentable { + let refresh: () async -> Void - // 스크롤뷰가 얼마나 당겨졌는지 0에서 시작해서 thresholdToRefresh까지를 1로 계산한 비율값 - private var pullDownRatio: CGFloat { - max(0, scrollYOffset - animationThreshold) - / max(1, refreshThreshold - animationThreshold) + func makeCoordinator() -> Coordinator { + Coordinator(refresh: refresh) } - func body(content: Content) -> some View { - ZStack(alignment: .top) { - AnimatingLogo(phase: $phase) - .onGeometryChange( - for: CGFloat.self, of: { $0.size.height }, - action: { - logoHeight = $0 - } - ) - .fixedSize() - .frame(height: max(pullToRefreshViewHeight, 0)) - .frame(maxWidth: .infinity) - .clipped() - - VStack(spacing: 0) { - if isClearRectNeeded { - SwiftUI.Color.clear - .frame(height: clearRectHeight) - } - - content - } - .onScrollPhaseChange { oldPhase, newPhase in - if newPhase == .interacting { - isScrolling = true - if phase.beforeRefreshing { - // 현재 스크롤이 트리거스크롤임 - isTriggerScrolling = true - } - } + func makeUIView(context: Context) -> ProbeView { + let view = ProbeView() + view.isUserInteractionEnabled = false + view.onHierarchyChange = { [weak coordinator = context.coordinator] in + coordinator?.attach(from: $0) + } + return view + } - if oldPhase == .interacting, newPhase != .interacting { - // 리프레시가 시작된 후 스크롤이 끝났을 때 - if phase.isRefreshing { - // 투명뷰를 추가한다. - isClearRectNeeded = true - // 스크롤 옵셋이 천천히 되돌아가도록 하기 위한 애니메이션 - withAnimation(.easeInOut(duration: 0.2)) { - // 로고와 패딩 영역이 천천히 줄어들도록 하기 위함 - pullToRefreshViewHeight = hangingHeight - clearRectHeight = hangingHeight - } - } + func updateUIView(_: ProbeView, context: Context) { + context.coordinator.refresh = refresh + } - // 리프레시가 끝나기 전에 스크롤을 드래그하여 위로 던지는 경우 - if phase.isRefreshing && scrollYOffset < 0 { - task?.cancel() - } + static func dismantleUIView(_: ProbeView, coordinator: Coordinator) { + coordinator.detach() + } + } - // 리프레시가 끝난 후 스크롤이 끝났을 때 - if phase.isWaitingToEnd { - // 엔딩 애니메이션 수행 - clearRectHeight = hangingHeight - phase = .ending - } + private final class ProbeView: UIView { + var onHierarchyChange: ((ProbeView) -> Void)? - isTriggerScrolling = false - isScrolling = false - } - } + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil else { return } + // 스크롤 뷰가 아직 계층에 붙기 전일 수 있어 다음 런루프에서 찾는다. + DispatchQueue.main.async { [weak self] in + guard let self, self.window != nil else { return } + self.onHierarchyChange?(self) } - .onChange(of: scrollYOffset) { _, scrollPosition in - // 트리거스크롤이 끝날때까지 - if isTriggerScrolling { - // 스크롤 옵셋 복귀 애니메이션 시작점을 업데이트 - clearRectHeight = scrollPosition - // 트리거스크롤 중에 리프레시 시작되었는데 스크롤 포지션이 다시 쓰레숄드 아래로 내려가는 경우 - if phase.isRefreshing && scrollPosition < refreshThreshold { - task?.cancel() - } - } + } - // 리프레시 시작전까지만 - if phase.beforeRefreshing { - // 로고와 패딩 영역의 높이를 증가시키고 pullDownRatio를 계산한다. - pullToRefreshViewHeight = scrollPosition - phase = .pulledDown(ratio: pullDownRatio) - } - } - .onChange(of: phase) { _, phase in - switch phase { - case .idle: - break - case .pulledDown(let ratio): - if ratio > 1 && isScrolling { - self.phase = .refreshing - } - case .refreshing: - if !isScrolling { - isClearRectNeeded = true - pullToRefreshViewHeight = hangingHeight - clearRectHeight = hangingHeight - } - task?.cancel() - task = Task { - await refresh() - do { - try Task.checkCancellation() - } catch { - if error is CancellationError { - if isTriggerScrolling { - self.phase = .pulledDown(ratio: pullDownRatio) - return - } - } - } - if isScrolling { - // 스크롤 끝나는 시점에 엔딩 애니메이션을 수행하기 위해 대기시킨다. - self.phase = .waitingToEnd - } else { - self.phase = .ending - } - } - case .waitingToEnd: break - case .ending: - withAnimation(.easeInOut(duration: 0.2)) { - isClearRectNeeded = false - pullToRefreshViewHeight = 0 - clearRectHeight = 0 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.phase = .idle - } - } - } + override func layoutSubviews() { + super.layoutSubviews() + onHierarchyChange?(self) } } - private struct AnimatingLogo: View { - enum Phase: Equatable { - case idle, pulledDown(ratio: CGFloat), refreshing, waitingToEnd, ending - - var beforeRefreshing: Bool { - switch self { - case .idle, .pulledDown: true - default: false - } - } + private final class Coordinator { + var refresh: () async -> Void + private let control = PullToRefreshControl() + private weak var scrollView: UIScrollView? + private var refreshControlObservation: NSKeyValueObservation? + private var task: Task? - var isRefreshing: Bool { - switch self { - case .refreshing: true - default: false - } - } + init(refresh: @escaping () async -> Void) { + self.refresh = refresh + control.addAction(UIAction { [weak self] _ in + self?.startRefresh() + }, for: .valueChanged) + } - var isWaitingToEnd: Bool { - switch self { - case .waitingToEnd: true - default: false + func attach(from probe: UIView) { + guard scrollView == nil, let found = Self.scrollView(around: probe) else { return } + scrollView = found + found.refreshControl = control + // SwiftUI가 업데이트 중에 refreshControl을 nil로 비우는 경우가 있어 다시 붙인다. + refreshControlObservation = found.observe(\.refreshControl) { [weak self] scrollView, _ in + MainActor.assumeIsolated { + guard let self, scrollView.refreshControl !== self.control else { return } + scrollView.refreshControl = self.control } } + } - var isEnding: Bool { - switch self { - case .ending: true - default: false - } + func detach() { + task?.cancel() + refreshControlObservation = nil + if scrollView?.refreshControl === control { + scrollView?.refreshControl = nil } + scrollView = nil } - @Binding private var phase: Phase - - init(phase: Binding) { - _phase = phase + private func startRefresh() { + task?.cancel() + task = Task { @MainActor [weak self] in + guard let self else { return } + await self.refresh() + self.control.endRefreshing() + } } - @State private var playbackMode: LottiePlaybackMode = .playing( - .toMarker("Loop", loopMode: .playOnce)) - @State private var scale = CGSize(width: 1, height: 1) - @State private var opacity: Double = 1 - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - LottieView(animation: .named(jsonName, bundle: .module)) - .playbackMode(playbackMode) - .configure { - $0.contentMode = .scaleAspectFit - } - .configuration(LottieConfiguration(renderingEngine: .automatic)) - .resizable() - .scaledToFill() - .frame(width: 48, height: 36) - .opacity(opacity) - .scaleEffect(scale) - .onChange(of: phase) { _, phase in - switch phase { - case .idle: - opacity = 1 - playbackMode = .paused(at: .progress(0)) - case .pulledDown(let ratio): - playbackMode = .paused(at: .progress(ratio)) - case .refreshing: - UIImpactFeedbackGenerator(style: .soft).impactOccurred() - withAnimation(.timingCurve(0.42, 0, 0.58, 1, duration: 0.25)) { - scale = CGSize(width: 1.1, height: 1.1) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - withAnimation(.timingCurve(0.42, 0, 0.58, 1, duration: 0.25)) { - scale = CGSize(width: 1, height: 1) - } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - playbackMode = .playing( - .fromProgress(0, toProgress: 1, loopMode: .loop)) - } - case .waitingToEnd: break - case .ending: - withAnimation(.timingCurve(0.25, 0.1, 0.25, 1, duration: 0.5)) { - opacity = 0 - } + /// 수정자는 스크롤 뷰 바깥(배경)에 깔리므로 조상을 거슬러 오르며 그 아래에서 찾는다. + /// + /// - 스크롤 뷰는 safe area까지 덮어 수정자 영역보다 크게 잡히므로, 그 영역을 감싸는 스크롤 뷰를 고른다. + /// 콘텐츠 안의 가로 캐러셀처럼 안쪽에 겹친 스크롤 뷰는 영역을 다 감싸지 못해 걸리지 않는다. + /// - 배경은 받치는 뷰 바로 앞에 놓이므로 뒤쪽 형제부터 본다. 스크롤 뷰가 교체되는 순간에는 사라질 이전 + /// 스크롤 뷰가 앞쪽에 함께 남아 있다. + /// - 다른 스크롤 뷰 안에 들어 있어도 가까운 이쪽이 먼저 걸리도록 조상 자신은 하위를 다 훑은 뒤에 본다. + private static func scrollView(around probe: UIView) -> UIScrollView? { + let area = probe.convert(probe.bounds, to: nil) + func covers(_ scrollView: UIScrollView) -> Bool { + scrollView.convert(scrollView.bounds, to: nil).insetBy(dx: -1, dy: -1).contains(area) + } + var branch: UIView = probe + while let current = branch.superview { + let siblings = current.subviews + let index = siblings.firstIndex(of: branch) ?? 0 + var queue = Array(siblings[(index + 1)...] + siblings[.. Void) -> some View { + modifier(PullToRefresh.Modifier(refresh: refresh)) + } + + /// 스크롤 뷰에 당겨서 새로고침(Pull-to-Refresh)을 추가합니다. + /// + /// - Parameters: + /// - scrollYOffset: 쓰지 않습니다. 컨트롤이 스크롤 위치를 직접 읽습니다 + /// - refresh: 갱신할 때 실행할 비동기 작업 + /// - Returns: 당겨서 새로고침이 추가된 뷰 + @available(*, deprecated, renamed: "pullToRefresh(refresh:)", message: "scrollYOffset을 쓰지 않습니다. pullToRefresh(refresh:)를 쓰세요.") public func pullToRefresh( scrollYOffset: Binding, refresh: @escaping () async -> Void ) -> some View { - modifier(PullToRefresh.Modifier(scrollYOffset: scrollYOffset, refresh: refresh)) + pullToRefresh(refresh: refresh) } } diff --git a/Sources/Montage/1 Components/9 Utilities/PullToRefreshControl.swift b/Sources/Montage/1 Components/9 Utilities/PullToRefreshControl.swift index b9deef86..47f10142 100644 --- a/Sources/Montage/1 Components/9 Utilities/PullToRefreshControl.swift +++ b/Sources/Montage/1 Components/9 Utilities/PullToRefreshControl.swift @@ -8,11 +8,11 @@ import Lottie import UIKit -/// ``PullToRefresh``의 로고 모션을 입힌 UIKit용 `UIRefreshControl`입니다. +/// Montage 로고 모션을 입힌 UIKit용 `UIRefreshControl`입니다. /// -/// ``PullToRefresh``는 SwiftUI `ScrollView` 전용이라 `UIScrollView`나 `WKWebView`에는 붙일 수 없습니다. /// 트리거와 inset 관리는 `UIRefreshControl`에 그대로 맡기고 시스템 스피너만 로고로 바꿉니다. -/// 사용법은 `UIRefreshControl`과 같습니다. +/// 사용법은 `UIRefreshControl`과 같습니다. SwiftUI 스크롤 뷰에는 +/// ``SwiftUI/View/pullToRefresh(refresh:)``가 이 컨트롤을 붙여 줍니다. /// /// ```swift /// let control = PullToRefreshControl() @@ -25,13 +25,13 @@ import UIKit /// - Note: `tintColor`는 시스템 스피너를 숨기는 데 쓰므로 바꾸지 않습니다. public final class PullToRefreshControl: UIRefreshControl { private enum Phase { - // waitingToEnd: 손가락을 대고 있는 동안 갱신이 끝난 상태. 놓을 때까지 로고를 유지한다(PullToRefresh와 같은 동작). + // waitingToEnd: 손가락을 대고 있는 동안 갱신이 끝난 상태. 놓을 때까지 로고를 유지한다. // settling: 로고가 사라진 뒤에도 아직 당겨져 있는 상태. 스크롤이 제자리로 돌아올 때까지 다시 그리지 않는다. case pulling, refreshing, waitingToEnd, ending, settling } private static let logoSize = CGSize(width: 48, height: 36) - // PullToRefresh와 같은 비율: 로고 높이의 절반에서 애니메이션을 시작해 4배에서 꽉 찬다. + // 로고 높이의 절반에서 애니메이션을 시작해 4배에서 꽉 찬다. private static let animationStart = logoSize.height / 2 private static let animationEnd = logoSize.height * 4 diff --git a/Sources/Montage/1 Components/9 Utilities/ScrollView.swift b/Sources/Montage/1 Components/9 Utilities/ScrollView.swift index 79ee87cd..7eb09c40 100644 --- a/Sources/Montage/1 Components/9 Utilities/ScrollView.swift +++ b/Sources/Montage/1 Components/9 Utilities/ScrollView.swift @@ -499,14 +499,8 @@ private struct ScrollTracker: ViewModifier { value: axis == .vertical ? ScrollOffsetPreferenceKey.clamped(metrics.contentOffset.y) : nil ) .if(onRefresh != nil) { - if #available(iOS 18, *) { - $0.pullToRefresh(scrollYOffset: $metrics.contentOffset.y) { - await onRefresh?() - } - } else { - $0.refreshable { - await onRefresh?() - } + $0.pullToRefresh { + await onRefresh?() } } } diff --git a/documentation/utilities/ios-extensions/swiftuicore.md b/documentation/utilities/ios-extensions/swiftuicore.md index ca2fa11a..ce45ce8d 100644 --- a/documentation/utilities/ios-extensions/swiftuicore.md +++ b/documentation/utilities/ios-extensions/swiftuicore.md @@ -577,43 +577,31 @@ View의 지오메트리 변경정보를 디바운스시켜서 받습니다.
-``func pullToRefresh(scrollYOffset: Binding, refresh: () async -> Void) -> some View`` +``func pullToRefresh(refresh: () async -> Void) -> some View`` -스크롤 뷰에 풀-투-리프레시(Pull-to-Refresh) 기능을 추가합니다. +스크롤 뷰에 당겨서 새로고침(Pull-to-Refresh)을 추가합니다. - **Parameters** | Parameter | Description | | --- | --- | - | `scrollYOffset` | 스크롤 뷰의 Y축 오프셋 바인딩. 당김 정도를 감지하는 데 사용됩니다. | - | `refresh` | 리프레시가 트리거될 때 실행될 비동기 클로저입니다. | + | `refresh` | 갱신할 때 실행할 비동기 작업 | - **Return Value** - 풀-투-리프레시 기능이, 추가된 뷰 + 당겨서 새로고침이 추가된 뷰 - **Discussion** - 사용자가 스크롤 뷰를 아래로 당기면 애니메이션과 함께 리프레시 기능을 제공합니다. iOS 18 이상에서 사용 가능하며, 로딩 애니메이션과 함께 당김 정도에 따른 시각적 피드백을 제공합니다. - > **Note** - > - > iOS 18 이상에서 사용 가능합니다. - + 당기면 로고가 그려지고, 놓으면 `refresh`가 끝날 때까지 로고가 돕니다. [PullToRefreshControl](/documentation/montage/pulltorefreshcontrol.md)을 쓰므로 UIKit 화면과 동작이 같습니다. - [ScrollView](/documentation/montage/scrollview.md)는 [onRefresh(_:)](/documentation/montage/scrollview/onrefresh(_:).md)가 이 수정자를 대신 붙여 주므로, 직접 붙이는 건 `SwiftUI.ScrollView`처럼 오프셋을 따로 재야 하는 경우입니다. + [ScrollView](/documentation/montage/scrollview.md)는 [onRefresh(_:)](/documentation/montage/scrollview/onrefresh(_:).md)가 이 수정자를 대신 붙여 주므로, 직접 붙이는 건 `SwiftUI.ScrollView`를 쓰는 경우입니다. 스크롤 뷰에 바로 붙입니다. ```swift - @State private var scrollYOffset: CGFloat = 0 - SwiftUI.ScrollView { content } - .onScrollGeometryChange(for: CGFloat.self) { geometry in - -(geometry.contentOffset.y + geometry.contentInsets.top) - } action: { _, newValue in - scrollYOffset = newValue - } - .pullToRefresh(scrollYOffset: $scrollYOffset) { + .pullToRefresh { await loadData() } ``` @@ -621,6 +609,28 @@ View의 지오메트리 변경정보를 디바운스시켜서 받습니다.
+~~``func pullToRefresh(scrollYOffset: Binding, refresh: () async -> Void) -> some View``~~ + + +스크롤 뷰에 당겨서 새로고침(Pull-to-Refresh)을 추가합니다. +> **Deprecated** +> +> scrollYOffset을 쓰지 않습니다. pullToRefresh(refresh:)를 쓰세요. + + +- **Parameters** + + | Parameter | Description | + | --- | --- | + | `scrollYOffset` | 쓰지 않습니다. 컨트롤이 스크롤 위치를 직접 읽습니다 | + | `refresh` | 갱신할 때 실행할 비동기 작업 | + +- **Return Value** + + 당겨서 새로고침이 추가된 뷰 +
+
+ ``func pushBadge(variant: PushBadge.Variant, size: PushBadge.Size, fontColor: SwiftUI.Color, backgroundColor: SwiftUI.Color, outlineBorder: Bool, outlineBorderColor: SwiftUI.Color, position: PushBadge.Position, inset: CGSize) -> some View`` diff --git a/documentation/utilities/ios-utility-components/pulltorefresh.md b/documentation/utilities/ios-utility-components/pulltorefresh.md index 0d21d003..d453f487 100644 --- a/documentation/utilities/ios-utility-components/pulltorefresh.md +++ b/documentation/utilities/ios-utility-components/pulltorefresh.md @@ -1,11 +1,16 @@ --- title: PullToRefresh +description: SwiftUI 스크롤 뷰에 [PullToRefreshControl](/documentation/montage/pulltorefreshcontrol.md)을 붙이는 당겨서 새로고침입니다. --- ```swift enum PullToRefresh ``` +## Overview + +SwiftUI `ScrollView` 밑에 깔린 `UIScrollView`를 찾아 `refreshControl`에 [PullToRefreshControl](/documentation/montage/pulltorefreshcontrol.md)을 붙입니다. UIKit 화면과 같은 컨트롤을 쓰므로 로고 모션과 트리거 거리, 갱신 중 높이가 같습니다. + ### Associated Extensions
@@ -14,49 +19,60 @@ enum PullToRefresh
-``func pullToRefresh(scrollYOffset: Binding, refresh: () async -> Void) -> some View`` +``func pullToRefresh(refresh: () async -> Void) -> some View`` -스크롤 뷰에 풀-투-리프레시(Pull-to-Refresh) 기능을 추가합니다. +스크롤 뷰에 당겨서 새로고침(Pull-to-Refresh)을 추가합니다. - **Parameters** | Parameter | Description | | --- | --- | - | `scrollYOffset` | 스크롤 뷰의 Y축 오프셋 바인딩. 당김 정도를 감지하는 데 사용됩니다. | - | `refresh` | 리프레시가 트리거될 때 실행될 비동기 클로저입니다. | + | `refresh` | 갱신할 때 실행할 비동기 작업 | - **Return Value** - 풀-투-리프레시 기능이, 추가된 뷰 + 당겨서 새로고침이 추가된 뷰 - **Discussion** - 사용자가 스크롤 뷰를 아래로 당기면 애니메이션과 함께 리프레시 기능을 제공합니다. iOS 18 이상에서 사용 가능하며, 로딩 애니메이션과 함께 당김 정도에 따른 시각적 피드백을 제공합니다. - > **Note** - > - > iOS 18 이상에서 사용 가능합니다. + 당기면 로고가 그려지고, 놓으면 `refresh`가 끝날 때까지 로고가 돕니다. [PullToRefreshControl](/documentation/montage/pulltorefreshcontrol.md)을 쓰므로 UIKit 화면과 동작이 같습니다. - - [ScrollView](/documentation/montage/scrollview.md)는 [onRefresh(_:)](/documentation/montage/scrollview/onrefresh(_:).md)가 이 수정자를 대신 붙여 주므로, 직접 붙이는 건 `SwiftUI.ScrollView`처럼 오프셋을 따로 재야 하는 경우입니다. + [ScrollView](/documentation/montage/scrollview.md)는 [onRefresh(_:)](/documentation/montage/scrollview/onrefresh(_:).md)가 이 수정자를 대신 붙여 주므로, 직접 붙이는 건 `SwiftUI.ScrollView`를 쓰는 경우입니다. 스크롤 뷰에 바로 붙입니다. ```swift - @State private var scrollYOffset: CGFloat = 0 - SwiftUI.ScrollView { content } - .onScrollGeometryChange(for: CGFloat.self) { geometry in - -(geometry.contentOffset.y + geometry.contentInsets.top) - } action: { _, newValue in - scrollYOffset = newValue - } - .pullToRefresh(scrollYOffset: $scrollYOffset) { + .pullToRefresh { await loadData() } ```
+
+ +``func pullToRefresh(scrollYOffset: Binding, refresh: () async -> Void) -> some View`` + + +스크롤 뷰에 당겨서 새로고침(Pull-to-Refresh)을 추가합니다. +> **Deprecated** +> +> scrollYOffset을 쓰지 않습니다. pullToRefresh(refresh:)를 쓰세요. + + +- **Parameters** + + | Parameter | Description | + | --- | --- | + | `scrollYOffset` | 쓰지 않습니다. 컨트롤이 스크롤 위치를 직접 읽습니다 | + | `refresh` | 갱신할 때 실행할 비동기 작업 | + +- **Return Value** + + 당겨서 새로고침이 추가된 뷰 +
+
diff --git a/packages/montage-mcp/data/components.json b/packages/montage-mcp/data/components.json index 98ca646f..f0d5e21c 100644 --- a/packages/montage-mcp/data/components.json +++ b/packages/montage-mcp/data/components.json @@ -2154,7 +2154,7 @@ "category": "utilities", "kind": "enum", "declaration": "enum PullToRefresh", - "summary": "", + "summary": "SwiftUI 스크롤 뷰에 PullToRefreshControl을 붙이는 당겨서 새로고침입니다.", "initializers": [], "modifiers": [], "staticMembers": [], @@ -2166,7 +2166,7 @@ "category": "uncategorized", "kind": "class", "declaration": "class PullToRefreshControl", - "summary": "PullToRefresh의 로고 모션을 입힌 UIKit용 UIRefreshControl입니다.", + "summary": "Montage 로고 모션을 입힌 UIKit용 UIRefreshControl입니다.", "initializers": [ { "signature": "init()"