diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3d57d54..ff9d7b0 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -116,7 +116,7 @@ reviews: drafts: true # 커밋이 추가될 때마다 변경된 부분만 자동으로 재리뷰 - auto_incremental_review: false + auto_incremental_review: true # ---------------------------------------------------------------- # # 채팅 기능 설정 diff --git a/.github/workflows/email-notify.yml b/.github/save/email-notify.yml similarity index 100% rename from .github/workflows/email-notify.yml rename to .github/save/email-notify.yml diff --git a/Examples/SampleApp/SampleApp.xcodeproj/project.pbxproj b/Examples/SampleApp/SampleApp.xcodeproj/project.pbxproj index 0467635..726c0f9 100644 --- a/Examples/SampleApp/SampleApp.xcodeproj/project.pbxproj +++ b/Examples/SampleApp/SampleApp.xcodeproj/project.pbxproj @@ -270,7 +270,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -303,7 +303,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Examples/SampleApp/SampleApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/SampleApp/SampleApp/App/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Examples/SampleApp/SampleApp/Assets.xcassets/AccentColor.colorset/Contents.json rename to Examples/SampleApp/SampleApp/App/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Examples/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/SampleApp/SampleApp/App/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Examples/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Examples/SampleApp/SampleApp/App/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Examples/SampleApp/SampleApp/Assets.xcassets/Contents.json b/Examples/SampleApp/SampleApp/App/Assets.xcassets/Contents.json similarity index 100% rename from Examples/SampleApp/SampleApp/Assets.xcassets/Contents.json rename to Examples/SampleApp/SampleApp/App/Assets.xcassets/Contents.json diff --git a/Examples/SampleApp/SampleApp/SampleApp.swift b/Examples/SampleApp/SampleApp/App/SampleApp.swift similarity index 88% rename from Examples/SampleApp/SampleApp/SampleApp.swift rename to Examples/SampleApp/SampleApp/App/SampleApp.swift index a8b6dee..9864503 100644 --- a/Examples/SampleApp/SampleApp/SampleApp.swift +++ b/Examples/SampleApp/SampleApp/App/SampleApp.swift @@ -11,7 +11,7 @@ import SwiftUI struct SampleApp: App { var body: some Scene { WindowGroup { - ContentView() + TabBarView() } } } diff --git a/Examples/SampleApp/SampleApp/App/TabBarView.swift b/Examples/SampleApp/SampleApp/App/TabBarView.swift new file mode 100644 index 0000000..6913d9a --- /dev/null +++ b/Examples/SampleApp/SampleApp/App/TabBarView.swift @@ -0,0 +1,33 @@ +// +// TabBarView.swift +// SampleApp +// +// Created by 김동현 on 4/8/26. +// + +import SwiftUI +import SwiftUI_Kit + +struct TabBarView: View { + @State private var selectedTab: Int = 0 + var body: some View { + TabView(selection: $selectedTab) { + WrapperListView() + .tabItem { + Label("Wrapper", systemImage: "0.square.fill") + } + .tag(0) + + NativeListView() + .tabItem { + Label("Native", systemImage: "1.square.fill") + } + .tag(1) + } + } +} + +#Preview { + TabBarView() +} + diff --git a/Examples/SampleApp/SampleApp/ContentView.swift b/Examples/SampleApp/SampleApp/ContentView.swift deleted file mode 100644 index 347049f..0000000 --- a/Examples/SampleApp/SampleApp/ContentView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ContentView.swift -// SampleApp -// -// Created by 김동현 on 4/8/26. -// - -import SwiftUI -import SwiftUI_Kit - -struct ContentView: View { - var body: some View { - VStack { - SKSampleView() - } - } -} - -#Preview { - ContentView() -} diff --git a/Examples/SampleApp/SampleApp/View/Native/0. NativeListView/NativeListView.swift b/Examples/SampleApp/SampleApp/View/Native/0. NativeListView/NativeListView.swift new file mode 100644 index 0000000..c304e50 --- /dev/null +++ b/Examples/SampleApp/SampleApp/View/Native/0. NativeListView/NativeListView.swift @@ -0,0 +1,18 @@ +// +// NativeListView.swift +// SampleApp +// +// Created by 김동현 on 4/8/26. +// + +import SwiftUI + +struct NativeListView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + NativeListView() +} diff --git a/Examples/SampleApp/SampleApp/View/Wrapper/0. WrapperListView/WrapperListView.swift b/Examples/SampleApp/SampleApp/View/Wrapper/0. WrapperListView/WrapperListView.swift new file mode 100644 index 0000000..3a964e9 --- /dev/null +++ b/Examples/SampleApp/SampleApp/View/Wrapper/0. WrapperListView/WrapperListView.swift @@ -0,0 +1,27 @@ +// +// WrapperListView.swift +// SampleApp +// +// Created by 김동현 on 4/8/26. +// + +import SwiftUI + +struct WrapperListView: View { + var body: some View { + NavigationStack { + List { + Section("") { + NavigationLink("SKWebView") { + SampleSKWebView() + .toolbar(.hidden, for: .tabBar) + } + } + } + } + } +} + +#Preview { + WrapperListView() +} diff --git a/Examples/SampleApp/SampleApp/View/Wrapper/1. SKWebView/SampleSKWebView.swift b/Examples/SampleApp/SampleApp/View/Wrapper/1. SKWebView/SampleSKWebView.swift new file mode 100644 index 0000000..2f34362 --- /dev/null +++ b/Examples/SampleApp/SampleApp/View/Wrapper/1. SKWebView/SampleSKWebView.swift @@ -0,0 +1,25 @@ +// +// SampleSKWebView.swift +// SampleApp +// +// Created by 김동현 on 4/8/26. +// + +import SwiftUI +import SwiftUI_Kit + +struct SampleSKWebView: View { + var body: some View { + SKWebView(url: URL(string: "https://www.naver.com")!) + .refreshable() + .refreshText("새로고침") + .refreshIndicatorColor(.blue) + .refreshTextColor(.blue) + .refreshIndicatorScale(1.0) + .ignoresSafeArea() + } +} + +#Preview { + SampleSKWebView() +} diff --git a/Sources/SwiftUI-Kit/Wrapper/SKWebView.swift b/Sources/SwiftUI-Kit/Wrapper/SKWebView.swift deleted file mode 100644 index a20d8c7..0000000 --- a/Sources/SwiftUI-Kit/Wrapper/SKWebView.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// SKWebView.swift -// SwiftUI-Kit -// -// Created by 김동현 on 4/8/26. -// - -import Foundation diff --git a/Sources/SwiftUI-Kit/Wrapper/SKWebView/SKWebView+Modifier.swift b/Sources/SwiftUI-Kit/Wrapper/SKWebView/SKWebView+Modifier.swift new file mode 100644 index 0000000..886f22a --- /dev/null +++ b/Sources/SwiftUI-Kit/Wrapper/SKWebView/SKWebView+Modifier.swift @@ -0,0 +1,110 @@ +// +// SKWebView+Modifier.swift +// SwiftUI-Kit +// +// Created by 김동현 on 4/8/26. +// + +import SwiftUI + +// MARK: - Modifier +public extension SKWebView { + + /// Pull to Refresh 기능을 활성화합니다. + /// + /// - Returns: 설정이 반영된 `SKWebView` + /// + /// 기본적으로 비활성화되어 있으며, 호출 시 활성화됩니다. + /// + /// ## Example + /// ```swift + /// SKWebView(url: url) + /// .refreshable() + /// ``` + func refreshable() -> Self { + var copy = self + copy.isRefreshEnabled = true + return copy + } + + /// Pull to Refresh 시 표시할 텍스트를 설정합니다. + /// + /// - Parameter text: 새로고침 시 상단에 표시될 문자열 + /// - Returns: 설정이 반영된 `SKWebView` + /// + /// ## Example + /// ```swift + /// SKWebView(url: url) + /// .refreshable() + /// .refreshText("새로고침 중...") + /// ``` + /// + /// - Note: 이 설정은 `refreshable()`이 활성화된 경우에만 적용됩니다. + func refreshText(_ text: String) -> Self { + var copy = self + copy.refreshText = text + return copy + } + + /// Pull to Refresh 텍스트의 색상을 설정합니다. + /// + /// - Parameter color: 텍스트에 적용할 색상 + /// - Returns: 설정이 반영된 `SKWebView` + /// + /// 기본값은 `.label`이며 시스템 다크/라이트 모드에 자동 대응됩니다. + /// + /// ## Example + /// ```swift + /// SKWebView(url: url) + /// .refreshable() + /// .refreshTextColor(.blue) + /// ``` + /// + /// - Note: 이 설정은 `refreshable()`이 활성화된 경우에만 적용됩니다. + func refreshTextColor(_ color: Color) -> Self { + var copy = self + copy.refreshTextColor = UIColor(color) + return copy + } + + /// Pull to Refresh 인디케이터의 색상을 설정합니다. + /// + /// - Parameter color: 인디케이터에 적용할 색상 + /// - Returns: 설정이 반영된 `SKWebView` + /// + /// iOS의 `tintColor`에 해당하는 값입니다. + /// + /// ## Example + /// ```swift + /// SKWebView(url: url) + /// .refreshable() + /// .refreshIndicatorColor(.blue) + /// ``` + /// + /// - Note: 이 설정은 `refreshable()`이 활성화된 경우에만 적용됩니다. + func refreshIndicatorColor(_ color: Color) -> Self { + var copy = self + copy.refreshIndicatorColor = UIColor(color) + return copy + } + + /// Pull to Refresh 인디케이터의 크기를 설정합니다. + /// + /// - Parameter scale: 인디케이터의 스케일 값입니다. 기본값은 `0.7`입니다. + /// - Returns: 설정이 반영된 `SKWebView` + /// + /// ## Example + /// ```swift + /// SKWebView(url: url) + /// .refreshable() + /// .refreshIndicatorScale(1.0) + /// ``` + /// + /// - Note: 이 설정은 `refreshable()`이 활성화된 경우에만 적용됩니다. + /// `CGAffineTransform`을 사용하여 크기를 조정합니다. + func refreshIndicatorScale(_ scale: CGFloat) -> Self { + var copy = self + copy.refreshIndicatorScale = scale + return copy + } +} diff --git a/Sources/SwiftUI-Kit/Wrapper/SKWebView/SKWebView.swift b/Sources/SwiftUI-Kit/Wrapper/SKWebView/SKWebView.swift new file mode 100644 index 0000000..5788804 --- /dev/null +++ b/Sources/SwiftUI-Kit/Wrapper/SKWebView/SKWebView.swift @@ -0,0 +1,173 @@ +// +// SKWebView.swift +// SwiftUI-Kit +// +// Created by 김동현 on 4/8/26. +// + +import SwiftUI +import WebKit + +/// SwiftUI에서 `WKWebView`를 사용할 수 있도록 감싼 웹뷰 래퍼입니다. +/// +/// `URL`을 입력받아 웹 페이지를 로드하며, +/// 필요에 따라 Pull to Refresh 기능을 활성화할 수 있습니다. +/// +/// 기본적으로 아래 기능을 제공합니다. +/// - URL 로드 +/// - 선택적 Pull to Refresh +/// - `WKNavigationDelegate` 연결 +/// +/// ## 사용 예시 +/// ```swift +/// SKWebView(url: URL(string: "https://www.naver.com")!) +/// .refreshable() +/// .refreshText("새로고침") +/// .refreshIndicatorColor(.blue) +/// .refreshTextColor(.blue) +/// .refreshIndicatorScale(1.0) +/// .ignoresSafeArea() +/// ``` +public struct SKWebView: UIViewRepresentable { + + // MARK: - Configuration + let url: URL + var refreshText: String + var refreshTextColor: UIColor + var refreshIndicatorColor: UIColor + var refreshIndicatorScale: CGFloat + var isRefreshEnabled: Bool + + public init(url: URL) { + self.url = url + self.refreshText = "" + self.refreshTextColor = .label + self.refreshIndicatorColor = .label + self.refreshIndicatorScale = 0.7 + self.isRefreshEnabled = false + } +} + +// MARK: - WebView Lifecycle +public extension SKWebView { + + /// UIKit View를 최초 1회 생성합니다. + /// + /// `makeUIView`는 실제 UIKit 뷰 인스턴스를 만드는 역할을 담당합니다. + /// `WKWebView`를 생성하고 초기 UI 설정을 적용합니다. + /// + /// - Parameter context: + /// - `Coordinator` 및 SwiftUI 환경 정보에 접근할 수 있는 컨텍스트 + /// - 추후 delegate 연결이나 상태 전달 시 활용 + /// + /// - Returns: 초기 설정이 적용된 `WKWebView` + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView(frame: .zero) + webView.navigationDelegate = context.coordinator + + if isRefreshEnabled { + + // Pull to Refresh 구성 + let refreshControl = UIRefreshControl() + + // 새로고침 시 보여줄 텍스트 스타일 지정 + refreshControl.attributedTitle = NSAttributedString( + string: refreshText, + attributes: [.foregroundColor: refreshTextColor] + ) + + // 인디케이터 색상 지정 + refreshControl.tintColor = refreshIndicatorColor + + // 인디케이터 크기 조절 + refreshControl.transform = CGAffineTransform( + scaleX: refreshIndicatorScale, + y: refreshIndicatorScale + ) + + // reload + refreshControl.addTarget( + context.coordinator, + action: #selector(Coordinator.handleRefresh), + for: .valueChanged + ) + + context.coordinator.webView = webView + context.coordinator.refreshControl = refreshControl + + webView.scrollView.refreshControl = refreshControl + webView.scrollView.bounces = true + } + return webView + } + + /// SwiftUI 상태가 변경될 때 기존 UIKit 뷰를 업데이트합니다. + /// + /// `updateUIView`는 이미 생성된 UIKit 뷰에 최신 SwiftUI 상태를 반영하는 역할을 합니다. + /// + /// - Parameters: + /// - uiView: 이미 생성된 `WKWebView` 인스턴스 + /// - context: `Coordinator` 및 SwiftUI 환경 정보에 접근할 수 있는 컨텍스트 + func updateUIView( + _ uiView: WKWebView, + context: Context + ) { + guard uiView.url != url else { return } + uiView.load(URLRequest(url: url)) + } +} + +// MARK: - Coordinator +public extension SKWebView { + + /// SwiftUI와 UIKit 사이의 중간 객체인 Coordinator를 생성합니다. + /// `Coordinator`는 보통 다음과 같은 상황에서 사용됩니다. + /// - delegate / dataSource 연결 + /// - UIKit 이벤트를 SwiftUI 상태로 전달 + /// - 외부 객체와의 중간 브리지 역할 + /// + /// 현재 구현에서는 `WKNavigationDelegate`를 연결하기 위한 용도로만 사용하며, + /// 아직 별도의 상태 전달 로직은 포함되어 있지 않습니다. + /// + /// - Returns: `WKNavigationDelegate` 역할을 수행하는 `Coordinator` + func makeCoordinator() -> Coordinator { + return Coordinator() + } + + /// `WKWebView`의 네비게이션 관련 delegate 이벤트를 처리하는 객체입니다. + final class Coordinator: NSObject, WKNavigationDelegate { + weak var webView: WKWebView? + weak var refreshControl: UIRefreshControl? + + /// Pull to Refresh 발생 시 호출됩니다. + @objc func handleRefresh() { + webView?.reload() + } + + /// 페이지 로딩 완료 시 refresh 상태를 종료합니다. + public func webView( + _ webView: WKWebView, + didFinish navigation: WKNavigation! + ) { + refreshControl?.endRefreshing() + } + + /// 페이지 로딩 실패 시 refresh 상태를 종료합니다. + public func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + refreshControl?.endRefreshing() + } + + /// 초기 로딩 실패 시 refresh 상태를 종료합니다. + public func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + refreshControl?.endRefreshing() + } + } +} diff --git a/Tests/SwiftUI-KitTests/SKWebViewTests.swift b/Tests/SwiftUI-KitTests/SKWebViewTests.swift new file mode 100644 index 0000000..2272dd0 --- /dev/null +++ b/Tests/SwiftUI-KitTests/SKWebViewTests.swift @@ -0,0 +1,69 @@ +// +// SKWebViewTests.swift +// SwiftUI-Kit +// +// Created by 김동현 on 4/8/26. +// + +import Testing +import UIKit +@testable import SwiftUI_Kit + +@MainActor +struct SKWebViewTests { + + // MARK: - Modifier + @Test("refreshable()는 새로고침 기능을 활성화한다") + func refreshable_enableRefresh() { + let sut = SKWebView(url: URL(string: "https://www.apple.com")!) + .refreshable() + #expect(sut.isRefreshEnabled == true) + } + + @Test("refreshTitle()은 새로고침 텍스트를 변경한다") + func refreshTitle_updatesText() { + let sut = SKWebView(url: URL(string: "https://www.apple.com")!) + .refreshText("새로고침...") + #expect(sut.refreshText == "새로고침...") + } + + @Test("refreshTextColor()는 텍스트 색상을 변경한다") + func refreshTextColor_updatesColor() { + let sut = SKWebView(url: URL(string: "https://www.apple.com")!) + .refreshTextColor(.blue) + + #expect(sut.refreshTextColor == .systemBlue) + } + + @Test("refreshIndicatorColor()는 인디케이터 색상을 변경한다") + func refreshIndicatorColor_updatesColor() { + let sut = SKWebView(url: URL(string: "https://www.apple.com")!) + .refreshIndicatorColor(.red) + + #expect(sut.refreshIndicatorColor == UIColor(.red)) + } + + @Test("refreshIndicatorScale()은 인디케이터 크기를 변경한다") + func refreshIndicatorScale_updatesScale() { + let sut = SKWebView(url: URL(string: "https://www.apple.com")!) + .refreshIndicatorScale(1.0) + + #expect(sut.refreshIndicatorScale == 1.0) + } + + @Test("modifier는 체이닝되어 모든 설정값을 반영한다") + func modifiers_chain_appliesAllConfigurations() { + let sut = SKWebView(url: URL(string: "https://www.apple.com")!) + .refreshable() + .refreshText("당겨서 새로고침") + .refreshTextColor(.blue) + .refreshIndicatorColor(.green) + .refreshIndicatorScale(0.9) + + #expect(sut.isRefreshEnabled == true) + #expect(sut.refreshText == "당겨서 새로고침") + #expect(sut.refreshTextColor == UIColor(.blue)) + #expect(sut.refreshIndicatorColor == UIColor(.green)) + #expect(sut.refreshIndicatorScale == 0.9) + } +}