diff --git a/Examples/.gitignore b/Examples/.gitignore new file mode 100644 index 0000000..bcdeb74 --- /dev/null +++ b/Examples/.gitignore @@ -0,0 +1,5 @@ +# Ignore build artifacts and Xcode project files +build/* + +# XcodeGen-generated demo project (regenerate with `xcodegen generate`) +ScreenUtilExamples.xcodeproj/* \ No newline at end of file diff --git a/Examples/.swift-format b/Examples/.swift-format new file mode 100644 index 0000000..bc19e76 --- /dev/null +++ b/Examples/.swift-format @@ -0,0 +1,8 @@ +{ + "version": 1, + "indentation": { "spaces": 4 }, + "lineLength": 160, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeEachArgument": false +} diff --git a/Examples/README.md b/Examples/README.md index 66b4fe5..60f783e 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -1,140 +1,49 @@ -# ScreenUtil Examples +# ScreenUtil Demo Apps -This directory contains comprehensive examples demonstrating how to use ScreenUtil in both UIKit and SwiftUI applications. +Two runnable iOS 17 demo apps — one pure SwiftUI, one pure UIKit — each a single +polished **GitHub profile page** whose every dimension flows through ScreenUtil. +Designed on the **iPhone 12 baseline (390×844)**: on an iPhone 12 the UI renders +1:1, and on a larger device (e.g. iPhone 17 Pro Max) the same design scales up +proportionally. Screenshot both to show the adaptation. -## Files +## Run -### UIKitExample.swift -Complete UIKit implementation examples including: - -#### Basic Usage -- **BasicUIKitViewController**: Demonstrates fundamental ScreenUtil usage - - Responsive layout with `.w`, `.h`, `.sp`, `.r` extensions - - Auto Layout with scaled constraints - - Screen metrics access and display - - Configuration setup - -#### Advanced Features -- **AdvancedUIKitViewController**: Shows advanced capabilities - - Batch operations for scaling multiple values - - Fast scaling with `FastScale` struct - - Custom scaling limits demonstration - - Performance benchmarking - - ScrollView with multiple sections - -#### Custom Components -- **ResponsiveCardView**: Custom UIView with built-in responsive design - - Responsive images, labels, and buttons - - Auto Layout with ScreenUtil scaling - - Shadow and corner radius scaling - -### SwiftUIExample.swift -Complete SwiftUI implementation examples including: - -#### Basic SwiftUI Views -- **BasicSwiftUIView**: Core SwiftUI usage patterns - - `.font(.system(size: 16.sp))` — scale the size with `.sp` inside native font APIs - - `.frame(width: 200.w, height: 50.h)` / `.padding(.horizontal, 20.w)` — scale inside native modifiers - - Grid layouts with responsive spacing - - Screen metrics display - -#### Advanced SwiftUI Features -- **ViewModifiersDemo**: Scaling values inside native SwiftUI modifiers - - `.frame(width: 200.w, height: 100.h)` - - `.clipShape(RoundedRectangle(cornerRadius: 12.r, style: .continuous))` - - Animated scaling examples - -#### Custom Components -- **ResponsiveProfileCard**: Professional profile card -- **ResponsiveProgressBar**: Animated progress indicator -- **ResponsiveStatsView**: Statistics grid layout -- **PerformanceDemoView**: Real-time performance testing - -## Key Features Demonstrated - -### 1. Basic Scaling -```swift -// UIKit -button.widthAnchor.constraint(equalToConstant: 100.w) -label.font = .systemFont(ofSize: 16.sp) -view.layer.cornerRadius = 8.r - -// SwiftUI -Text("Hello").font(.system(size: 16.sp)) -Rectangle().frame(width: 100.w, height: 50.h) +```bash +cd Examples +brew install xcodegen # one-time +xcodegen generate +open ScreenUtilExamples.xcodeproj ``` -### 2. Configuration -```swift -// Set design size (e.g., iPhone 13 Pro) -ScreenUtil.shared.configure(with: .iPhone13Pro) +Pick a scheme and run: -// Custom configuration -let config = ScreenUtilConfiguration( - designSize: CGSize(width: 390, height: 844), - minTextAdapt: true, - scalingLimits: .default -) -ScreenUtil.shared.configure(with: config) -``` +- **ScreenUtilSwiftUIDemo** — pure SwiftUI (`@Observable` model, `NavigationStack`, `@Environment(\.screenUtil)`). +- **ScreenUtilUIKitDemo** — pure UIKit (`UIScene`, Auto Layout, stored-property subviews). -### 3. Batch Operations -```swift -let values = [10, 20, 30, 40, 50] -let scaledWidths = ScreenUtil.shared.batchScaler.widths(values) -let scaledHeights = ScreenUtil.shared.batchScaler.heights(values) -``` - -### 4. Fast Scaling -```swift -let fastScale = ScreenUtil.shared.fastScale -let scaledSize = fastScale.size(CGSize(width: 100, height: 50)) -let scaledPoint = fastScale.point(CGPoint(x: 10, y: 20)) -``` +## What it shows -### 5. Screen Metrics -```swift -let metrics = ScreenUtil.shared.getScreenMetrics() -print("Screen: \(metrics.width) x \(metrics.height)") -print("Safe Area Top: \(metrics.safeAreaInsets.top)") -print("Device: \(ScreenUtil.shared.deviceType)") -``` - -### 6. Debug Tools -```swift -// Print current configuration -ScreenUtilDebug.printCurrentConfiguration() - -// Run performance benchmark -ScreenUtilDebug.benchmarkScalingOperations() -``` +Each app composes the same sections, all scaled from the design baseline: -## Usage Instructions +| Section | ScreenUtil APIs exercised | +|---------|---------------------------| +| **Header** (banner, avatar, name, bio) | numeric `.w .h .sp .r`, `CGSize.scaled`, `UIView.cornerRadius/borderWidth/size`, `UIFont.systemFont(…, scaled:)` | +| **Stats row** (repos/followers/following) | `BatchScaler.fontSizes`, `UIEdgeInsets.scaled` | +| **Highlights** (horizontal strip) | `FastScale` (`width/height/text/size/point/rect`), `withFastScale`, `BatchScaler.points/.rects` | +| **Tag chips** | `BatchScaler.widths/.radii`, `withBatchScaler` | +| **Device & Scaling card** | `getScreenMetrics`, `screenWidth/Height`, `scaleWidth/Height/Text`, `deviceType`, `safeArea*`, `statusBarHeight`, `scale/fastScale(for:scaleType:)` (all `ScaleType` cases), `UIFont.customFont`, and a one-line "bulk self-test" covering `BatchScaler.heights/.sizes/.scale`, `BatchScaler.edgeInsets` (UIKit), `CGRect.scaled/.responsive` | -### For UIKit Projects -1. Import ScreenUtil in your view controllers -2. Configure ScreenUtil in your app launch or viewDidLoad -3. Use the scaling extensions (.w, .h, .sp, .r) throughout your UI code -4. Copy and adapt the example view controllers as needed +### Real data + offline-safe -### For SwiftUI Projects -1. Import ScreenUtil in your SwiftUI views -2. Configure ScreenUtil in your app startup or view onAppear -3. Use the font and view modifiers for responsive design -4. Leverage property wrappers for automatic scaling -5. Copy and adapt the example views as needed +The profile loads from the live GitHub public API +(`api.github.com/users/{login}`) — real avatar, repo and follower counts. On any +failure (offline / rate-limited) it falls back to a bundled `sample-user.json`, +so the demo (and your screenshots) never show a spinner or empty state. -## Performance Notes +## Verify the scaling -- Fast scaling operations are ~1.4x faster than standard scaling -- Batch operations are optimized for processing multiple values -- All operations are thread-safe and can be used from any queue -- Memory usage is minimal with efficient caching strategies +Open the **Device & Scaling** card and read `scaleW · scaleH`: -## Best Practices +- iPhone 12 → `1.00 · 1.00` (renders exactly as designed) +- iPhone 17 Pro Max → ~`1.13 · 1.13` (everything scaled up proportionally) -1. **Configure Early**: Set up ScreenUtil configuration as early as possible in your app lifecycle -2. **Use Appropriate Scale Types**: Choose .w for widths, .h for heights, .sp for text, .r for radii -3. **Leverage Batch Operations**: Use batch scaling when processing multiple values -4. **Test on Multiple Devices**: Verify your responsive design on different screen sizes -5. **Monitor Performance**: Use the built-in debugging tools to optimize performance \ No newline at end of file +> The generated `ScreenUtilExamples.xcodeproj` is not committed; regenerate it with `xcodegen generate`. diff --git a/Examples/Shared/Data/GitHubUser.swift b/Examples/Shared/Data/GitHubUser.swift new file mode 100644 index 0000000..4822575 --- /dev/null +++ b/Examples/Shared/Data/GitHubUser.swift @@ -0,0 +1,45 @@ +// +// GitHubUser.swift +// ScreenUtil Examples +// +// GitHub public-user DTO (mirrors the API JSON) + mapping to the domain Profile. +// Created by Dicky Darmawan on 27/06/26. +// + +import Foundation + +/// A GitHub public user, decoded from `api.github.com/users/{login}`. +struct GitHubUser: Codable, Equatable, Sendable { + let login: String + let name: String? + let bio: String? + let avatarURL: URL + let publicRepos: Int + let followers: Int + let following: Int + let location: String? + let company: String? + let blog: String? + + enum CodingKeys: String, CodingKey { + case login, name, bio, followers, following, location, company, blog + case avatarURL = "avatar_url" + case publicRepos = "public_repos" + } +} + +extension GitHubUser { + /// Maps the API DTO to the domain `Profile`. Resolves the display name once + /// (`name` falls back to `login`) so views never repeat the `?? login` dance. + func toDomain() -> Profile { + Profile( + username: login, + name: name ?? login, + bio: bio, + avatar: avatarURL, + repos: publicRepos, + followers: followers, + following: following + ) + } +} diff --git a/Examples/Shared/Data/ProfileRepository.swift b/Examples/Shared/Data/ProfileRepository.swift new file mode 100644 index 0000000..3d9a5cd --- /dev/null +++ b/Examples/Shared/Data/ProfileRepository.swift @@ -0,0 +1,44 @@ +// +// ProfileRepository.swift +// ScreenUtil Examples +// +// Data layer: abstracts profile loading behind a protocol (Dependency Inversion). +// Created by Dicky Darmawan on 27/06/26. +// + +import Foundation + +/// Loads a `Profile`. Abstraction the view model depends on (not URLSession). +protocol ProfileRepository: Sendable { + func fetch() async throws -> Profile +} + +/// Live implementation: GitHub public API → `GitHubUser` DTO → `Profile`. +/// Throws on bad URL, non-200, transport error, or decode failure (no fallback). +struct LiveProfileRepository: ProfileRepository { + let username: String + var session: URLSession = .shared + + func fetch() async throws -> Profile { + guard let url = URL(string: "https://api.github.com/users/\(username)") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw URLError(.badServerResponse) + } + return try JSONDecoder().decode(GitHubUser.self, from: data).toDomain() + } +} + +/// Stub for SwiftUI previews and unit tests (no network). Holds a `@Sendable` +/// closure rather than a `Result` — `any Error` is not +/// Sendable, so a stored Result would break `ProfileRepository: Sendable`; a +/// `@Sendable` function type is Sendable and still lets callers throw any error. +struct StubProfileRepository: ProfileRepository { + private let handler: @Sendable () throws -> Profile + init(_ handler: @escaping @Sendable () throws -> Profile) { self.handler = handler } + func fetch() async throws -> Profile { try handler() } +} diff --git a/Examples/Shared/Domain/LoadState.swift b/Examples/Shared/Domain/LoadState.swift new file mode 100644 index 0000000..35ec6f8 --- /dev/null +++ b/Examples/Shared/Domain/LoadState.swift @@ -0,0 +1,24 @@ +// +// LoadState.swift +// ScreenUtil Examples +// +// Generic async-load state machine driving the profile screens. +// Created by Dicky Darmawan on 27/06/26. +// + +/// The state of an asynchronously loaded value. +/// +/// Not `Sendable`: `failed(Error)` wraps `any Error` (not Sendable). This type is +/// mutated and read only on `@MainActor`, so it never crosses an actor boundary. +enum LoadState { + case idle + case loading + case loaded(Value) + case failed(Error) + + /// The loaded value, or `nil` in any other state. + var value: Value? { + if case .loaded(let value) = self { return value } + return nil + } +} diff --git a/Examples/Shared/Domain/Profile.swift b/Examples/Shared/Domain/Profile.swift new file mode 100644 index 0000000..571d244 --- /dev/null +++ b/Examples/Shared/Domain/Profile.swift @@ -0,0 +1,33 @@ +// +// Profile.swift +// ScreenUtil Examples +// +// Domain model the views render — mapped from the GitHubUser DTO. +// Created by Dicky Darmawan on 27/06/26. +// + +import Foundation + +/// A GitHub profile, in domain terms the UI cares about (no serialization detail). +struct Profile: Equatable, Sendable { + let username: String + let name: String + let bio: String? + let avatar: URL + let repos: Int + let followers: Int + let following: Int +} + +extension Profile { + /// Self-contained sample for previews and tests (no network, no bundle). + static let preview = Profile( + username: "octocat", + name: "The Octocat", + bio: "Responsive layouts, scaled with ScreenUtil.", + avatar: URL(string: "https://avatars.githubusercontent.com/u/583231")!, + repos: 8, + followers: 3400, + following: 180 + ) +} diff --git a/Examples/Shared/Support/ExampleProfile.swift b/Examples/Shared/Support/ExampleProfile.swift new file mode 100644 index 0000000..9a44e58 --- /dev/null +++ b/Examples/Shared/Support/ExampleProfile.swift @@ -0,0 +1,19 @@ +// +// ExampleProfile.swift +// ScreenUtil Examples +// +// Single source of truth for what both demos render + the ScreenUtil baseline, +// so the UIKit and SwiftUI showcases stay in lockstep. +// Created by Dicky Darmawan on 27/06/26. +// + +/// Shared facts driving both profile apps. Centralised here so the two showcases +/// never drift apart. (The ScreenUtil baseline stays inline in each app entry point +/// on purpose, so the `configure(with:)` call is visible as usage documentation.) +enum ExampleProfile { + /// GitHub handle both apps load. + static let username = "Dicky019" + + /// Tag chips shown on the profile. + static let tags = ["swift", "ios", "uikit", "swiftui"] +} diff --git a/Examples/Shared/Support/Format.swift b/Examples/Shared/Support/Format.swift new file mode 100644 index 0000000..9f82469 --- /dev/null +++ b/Examples/Shared/Support/Format.swift @@ -0,0 +1,25 @@ +// +// Format.swift +// ScreenUtil Examples +// +// Single source of truth for the demo's value formatting (shared by both apps). +// Created by Dicky Darmawan on 21/06/26. +// + +import CoreGraphics +import Foundation + +/// Formatting helpers used across the demo to render scaled values. +enum Format { + /// e.g. `123` + static func integer(_ value: CGFloat) -> String { String(format: "%.0f", value) } + + /// e.g. `123.4` + static func oneDecimal(_ value: CGFloat) -> String { String(format: "%.1f", value) } + + /// e.g. `1.25` + static func twoDecimals(_ value: CGFloat) -> String { String(format: "%.2f", value) } + + /// e.g. `120×80` + static func describe(_ size: CGSize) -> String { String(format: "%.0f×%.0f", size.width, size.height) } +} diff --git a/Examples/Shared/ViewModel/ProfileViewModel.swift b/Examples/Shared/ViewModel/ProfileViewModel.swift new file mode 100644 index 0000000..bad4036 --- /dev/null +++ b/Examples/Shared/ViewModel/ProfileViewModel.swift @@ -0,0 +1,41 @@ +// +// ProfileViewModel.swift +// ScreenUtil Examples +// +// Shared observable view model: drives profile loading as a LoadState machine +// and publishes the live scale metrics. SwiftUI observes it; UIKit drives it. +// Created by Dicky Darmawan on 27/06/26. +// + +import Observation +import ScreenUtil + +@MainActor +@Observable +final class ProfileViewModel { + private(set) var state: LoadState = .idle + private(set) var metrics: ScreenMetrics = ScreenUtil.shared.getScreenMetrics() + + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + /// Loads the profile through the repository. Ignored if already loading + /// (re-entrancy guard). Refreshes `metrics` afterwards: by now ScreenUtil's + /// scene-activate observer has rebuilt the snapshot with the real window + /// size + safe-area, and publishing it re-renders the metrics card. + func load() async { + if case .loading = state { return } + state = .loading + do { + state = .loaded(try await repository.fetch()) + } catch { + state = .failed(error) + } + metrics = ScreenUtil.shared.getScreenMetrics() + } + + func retry() async { await load() } +} diff --git a/Examples/Sources/SwiftUIExample/main.swift b/Examples/Sources/SwiftUIExample/main.swift deleted file mode 100644 index 305b89a..0000000 --- a/Examples/Sources/SwiftUIExample/main.swift +++ /dev/null @@ -1,833 +0,0 @@ -// -// SwiftUIExample.swift -// ScreenUtil Examples -// -// Comprehensive SwiftUI usage examples -// - -import SwiftUI -import ScreenUtil - -// MARK: - Basic SwiftUI Example - -struct BasicSwiftUIView: View { - var body: some View { - NavigationView { - ScrollView { - VStack(spacing: 24.h) { - // MARK: - Header Section - headerSection - - // MARK: - Feature Cards - featuresSection - - // MARK: - Interactive Elements - interactiveSection - - // MARK: - Info Section - infoSection - } - .padding(.horizontal, 20.w) - .padding(.top, 16.h) - } - .navigationTitle("ScreenUtil SwiftUI") - .navigationBarTitleDisplayMode(.large) - } - .onAppear { - configureScreenUtil() - } - } - - private func configureScreenUtil() { - let screenUtil = ScreenUtil.shared - // Configure with iPhone 13 Pro design size - screenUtil.configure(with: .iPhone13Pro) - - // Optional: Print current configuration for debugging - ScreenUtilDebug.printCurrentConfiguration() - } - - // MARK: - Header Section - - private var headerSection: some View { - VStack(spacing: 12.h) { - Image(systemName: "iphone.gen3") - .font(.system(size: 48.sp)) - .foregroundColor(.blue) - - Text("Responsive Design") - .font(.system(size: (28).sp, weight: .bold)) - .multilineTextAlignment(.center) - - Text("Built with ScreenUtil for perfect adaptation across all devices") - .font(.system(size: (16).sp)) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .lineLimit(3) - } - .padding(.vertical, 24.h) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 16.r) - .fill(Color.blue.opacity(0.1)) - ) - } - - // MARK: - Features Section - - private var featuresSection: some View { - VStack(spacing: 16.h) { - HStack { - Text("Features") - .font(.system(size: (22).sp, weight: .semibold)) - Spacer() - } - - LazyVGrid(columns: [ - GridItem(.flexible(), spacing: 12.w), - GridItem(.flexible(), spacing: 12.w) - ], spacing: 12.h) { - FeatureCard( - icon: "scalemass", - title: "Auto Scaling", - description: "Automatically scales all elements" - ) - - FeatureCard( - icon: "speedometer", - title: "High Performance", - description: "Optimized for 60fps animations" - ) - - FeatureCard( - icon: "shield.checkered", - title: "Thread Safe", - description: "Safe concurrent access guaranteed" - ) - - FeatureCard( - icon: "gearshape.2", - title: "Configurable", - description: "Flexible scaling limits and options" - ) - } - } - } - - // MARK: - Interactive Section - - private var interactiveSection: some View { - VStack(spacing: 20.h) { - HStack { - Text("Interactive Demo") - .font(.system(size: (22).sp, weight: .semibold)) - Spacer() - } - - // Responsive buttons - HStack(spacing: 12.w) { - ResponsiveButton( - title: "Primary", - style: .primary, - action: { print("Primary button tapped") } - ) - - ResponsiveButton( - title: "Secondary", - style: .secondary, - action: { print("Secondary button tapped") } - ) - } - - // Scaling demonstration - ScalingDemoView() - - // Performance test - NavigationLink(destination: PerformanceDemoView()) { - HStack { - Image(systemName: "timer") - Text("Performance Tests") - Spacer() - Image(systemName: "chevron.right") - } - .font(.system(size: (16).sp, weight: .medium)) - .padding(.horizontal, 16.w) - .padding(.vertical, 12.h) - .background( - RoundedRectangle(cornerRadius: 8.r) - .fill(Color.green.opacity(0.1)) - ) - .foregroundColor(.green) - } - } - } - - // MARK: - Info Section - - private var infoSection: some View { - VStack(alignment: .leading, spacing: 12.h) { - Text("Screen Metrics") - .font(.system(size: (18).sp, weight: .semibold)) - - InfoRow(label: "Screen Size", value: "\(Int(ScreenUtil.shared.getScreenMetrics().width)) x \(Int(ScreenUtil.shared.getScreenMetrics().height)) pts") - InfoRow(label: "Scale Factor", value: "\(ScreenUtil.shared.getScreenMetrics().scale)x") - InfoRow(label: "Safe Area Top", value: "\(Int(ScreenUtil.shared.getScreenMetrics().safeAreaInsets.top)) pts") - InfoRow(label: "Safe Area Bottom", value: "\(Int(ScreenUtil.shared.getScreenMetrics().safeAreaInsets.bottom)) pts") - InfoRow(label: "Device Type", value: "\(ScreenUtil.shared.deviceType)") - InfoRow(label: "Width Scale", value: String(format: "%.3f", ScreenUtil.shared.scaleWidth)) - InfoRow(label: "Height Scale", value: String(format: "%.3f", ScreenUtil.shared.scaleHeight)) - InfoRow(label: "Text Scale", value: String(format: "%.3f", ScreenUtil.shared.scaleText)) - } - .padding(16.w) - .background( - RoundedRectangle(cornerRadius: 12.r) - .fill(Color.secondary.opacity(0.1)) - ) - } -} - -// MARK: - Feature Card Component - -struct FeatureCard: View { - let icon: String - let title: String - let description: String - - var body: some View { - VStack(spacing: 8.h) { - Image(systemName: icon) - .font(.system(size: 24.sp)) - .foregroundColor(.blue) - - Text(title) - .font(.system(size: (14).sp, weight: .semibold)) - .lineLimit(1) - - Text(description) - .font(.system(size: (12).sp)) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16.h) - .padding(.horizontal, 12.w) - .background( - RoundedRectangle(cornerRadius: 12.r) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.1), radius: 4.r, y: 2.h) - ) - } -} - -// MARK: - Responsive Button Component - -struct ResponsiveButton: View { - let title: String - let style: ButtonStyle - let action: () -> Void - - enum ButtonStyle { - case primary, secondary - } - - var body: some View { - Button(action: action) { - Text(title) - .font(.system(size: (16).sp, weight: .medium)) - .foregroundColor(style == .primary ? .white : .blue) - .frame(maxWidth: .infinity) - .frame(height: 44.h) - .background( - RoundedRectangle(cornerRadius: 8.r) - .fill(style == .primary ? Color.blue : Color.clear) - .overlay( - RoundedRectangle(cornerRadius: 8.r) - .stroke(Color.blue, lineWidth: style == .secondary ? 1.w : 0) - ) - ) - } - } -} - -// MARK: - Scaling Demo Component - -struct ScalingDemoView: View { - @State private var selectedScale: ScaleType = .width - private let scaleTypes: [ScaleType] = [.width, .height, .text, .radius] - - var body: some View { - VStack(spacing: 16.h) { - Text("Scaling Types Demo") - .font(.system(size: (16).sp, weight: .medium)) - - // Scale type picker - Picker("Scale Type", selection: $selectedScale) { - ForEach(scaleTypes, id: \.self) { scaleType in - Text(String(describing: scaleType).capitalized) - .tag(scaleType) - } - } - .pickerStyle(SegmentedPickerStyle()) - - // Visual demonstration - HStack(spacing: 16.w) { - ForEach(1...5, id: \.self) { index in - let baseValue: CGFloat = CGFloat(index * 20) - let scaledValue = ScreenUtil.shared.scale(for: baseValue, scaleType: selectedScale) - - VStack(spacing: 4.h) { - Rectangle() - .fill(Color.blue) - .frame(width: 20.w, height: scaledValue) - - Text("\(Int(scaledValue))") - .font(.system(size: (10).sp)) - .foregroundColor(.secondary) - } - } - } - .frame(height: 120.h) - .padding(16.w) - .background( - RoundedRectangle(cornerRadius: 8.r) - .fill(Color.gray.opacity(0.1)) - ) - } - } -} - -// MARK: - Info Row Component - -struct InfoRow: View { - let label: String - let value: String - - var body: some View { - HStack { - Text(label) - .font(.system(size: (14).sp)) - .foregroundColor(.secondary) - - Spacer() - - Text(value) - .font(.system(size: (14).sp, weight: .medium)) - } - } -} - -// MARK: - Advanced SwiftUI Examples - -struct AdvancedSwiftUIView: View { - var body: some View { - NavigationView { - List { - NavigationLink("View Modifiers", destination: ViewModifiersDemo()) - NavigationLink("Responsive Layout", destination: ResponsiveLayoutDemo()) - NavigationLink("Custom Components", destination: CustomComponentsDemo()) - } - .navigationTitle("Advanced Examples") - } - } -} - -// MARK: - View Modifiers Demo - -struct ViewModifiersDemo: View { - var body: some View { - ScrollView { - VStack(spacing: 24.h) { - Text("View Modifiers Demo") - .font(.system(size: (24).sp, weight: .bold)) - - // Responsive padding - Text("Responsive Padding") - .font(.system(size: (16).sp, weight: .medium)) - .padding(.horizontal, 20.w) - .padding(.vertical, 8.w) - .background(Color.blue.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8.r, style: .continuous)) - - // Responsive frame - Rectangle() - .fill(Color.green) - .frame(width: 200.w, height: 100.h) - .clipShape(RoundedRectangle(cornerRadius: 12.r, style: .continuous)) - - // Custom responsive modifier - Text("Custom Modifier") - .font(.system(size: (14).sp)) - .responsiveCard() - - // Animated scaling - AnimatedScalingExample() - - Spacer(minLength: 40.h) - } - .padding(.horizontal, 20.w) - } - .navigationTitle("View Modifiers") - .navigationBarTitleDisplayMode(.inline) - } -} - -struct AnimatedScalingExample: View { - @State private var isScaled = false - - var body: some View { - VStack(spacing: 16.h) { - Text("Animated Scaling") - .font(.system(size: (18).sp, weight: .semibold)) - - Circle() - .fill(Color.purple) - .frame( - width: isScaled ? 100.w : 60.w, - height: isScaled ? 100.h : 60.h - ) - .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isScaled) - - Button("Toggle Scale") { - isScaled.toggle() - } - .font(.system(size: (16).sp, weight: .medium)) - .foregroundColor(.white) - .padding(.horizontal, 24.w) - .padding(.vertical, 12.h) - .background(Color.purple) - .clipShape(RoundedRectangle(cornerRadius: 8.r, style: .continuous)) - } - } -} - -// MARK: - Responsive Layout Demo - -struct ResponsiveLayoutDemo: View { - var body: some View { - ScrollView { - VStack(spacing: 24.h) { - Text("Responsive Layout") - .font(.system(size: (24).sp, weight: .bold)) - - // Responsive grid - ResponsiveGrid() - - // Adaptive stack - AdaptiveStackExample() - - // Responsive spacing - ResponsiveSpacingExample() - - Spacer(minLength: 40.h) - } - .padding(.horizontal, 20.w) - } - .navigationTitle("Responsive Layout") - .navigationBarTitleDisplayMode(.inline) - } -} - -struct ResponsiveGrid: View { - private let items = Array(1...12) - private let columns = [ - GridItem(.flexible(), spacing: 8.w), - GridItem(.flexible(), spacing: 8.w), - GridItem(.flexible(), spacing: 8.w), - GridItem(.flexible(), spacing: 8.w) - ] - - var body: some View { - VStack(alignment: .leading, spacing: 12.h) { - Text("Responsive Grid") - .font(.system(size: (18).sp, weight: .semibold)) - - LazyVGrid(columns: columns, spacing: 8.h) { - ForEach(items, id: \.self) { item in - RoundedRectangle(cornerRadius: 6.r) - .fill(Color.blue.opacity(0.7)) - .aspectRatio(1, contentMode: .fit) - .overlay( - Text("\(item)") - .font(.system(size: (14).sp, weight: .bold)) - .foregroundColor(.white) - ) - } - } - } - } -} - -struct AdaptiveStackExample: View { - var body: some View { - VStack(alignment: .leading, spacing: 12.h) { - Text("Adaptive Stack") - .font(.system(size: (18).sp, weight: .semibold)) - - // This would adapt based on screen size in a real implementation - HStack(spacing: 12.w) { - ForEach(1...3, id: \.self) { index in - VStack { - Circle() - .fill(Color.orange) - .frame(width: 40.w, height: 40.h) - - Text("Item \(index)") - .font(.system(size: (12).sp)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16.h) - .background( - RoundedRectangle(cornerRadius: 8.r) - .fill(Color.orange.opacity(0.1)) - ) - } - } - } - } -} - -struct ResponsiveSpacingExample: View { - var body: some View { - VStack(alignment: .leading, spacing: 12.h) { - Text("Responsive Spacing") - .font(.system(size: (18).sp, weight: .semibold)) - - VStack(spacing: 16.h) { - ForEach(1...4, id: \.self) { index in - HStack { - Rectangle() - .fill(Color.red) - .frame(width: 4.w, height: 20.h) - - Text("Spacing example \(index)") - .font(.system(size: (14).sp)) - - Spacer() - } - } - } - } - } -} - -// MARK: - Performance Demo - -struct PerformanceDemoView: View { - @State private var isRunning = false - @State private var results = "" - - var body: some View { - VStack(spacing: 24.h) { - Text("Performance Testing") - .font(.system(size: (24).sp, weight: .bold)) - - Button(action: runPerformanceTest) { - Text(isRunning ? "Running..." : "Run Performance Test") - .font(.system(size: (16).sp, weight: .medium)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .frame(height: 44.h) - .background( - RoundedRectangle(cornerRadius: 8.r) - .fill(isRunning ? Color.gray : Color.blue) - ) - } - .disabled(isRunning) - - if !results.isEmpty { - ScrollView { - Text(results) - .font(.system(size: (12).sp, design: .monospaced)) - .padding(16.w) - .background( - RoundedRectangle(cornerRadius: 8.r) - .fill(Color.gray.opacity(0.1)) - ) - } - } - - Spacer() - } - .padding(.horizontal, 20.w) - .navigationTitle("Performance Test") - .navigationBarTitleDisplayMode(.inline) - } - - private func runPerformanceTest() { - isRunning = true - results = "" - - DispatchQueue.global(qos: .userInitiated).async { - // Run performance benchmarks - let startTime = CFAbsoluteTimeGetCurrent() - - // Test standard scaling - let standardTest = measureTime { - for _ in 0..<100000 { - _ = 100.0.w - } - } - - // Test fast scaling - let fastTest = measureTime { - let fastScale = ScreenUtil.shared.fastScale - for _ in 0..<100000 { - _ = fastScale.width(100.0) - } - } - - // Test batch operations - let batchTest = measureTime { - let values = Array(1...1000) - for _ in 0..<100 { - _ = ScreenUtil.shared.batchScaler.widths(values) - } - } - - let totalTime = CFAbsoluteTimeGetCurrent() - startTime - - DispatchQueue.main.async { - results = """ - Performance Test Results: - - Standard Scaling: \(String(format: "%.3f", standardTest * 1000))ms - Fast Scaling: \(String(format: "%.3f", fastTest * 1000))ms - Batch Operations: \(String(format: "%.3f", batchTest * 1000))ms - - Total Test Time: \(String(format: "%.3f", totalTime))s - - Fast scaling is \(String(format: "%.1f", standardTest / fastTest))x faster - """ - isRunning = false - } - } - } - - private func measureTime(_ operation: () -> T) -> TimeInterval { - let startTime = CFAbsoluteTimeGetCurrent() - _ = operation() - return CFAbsoluteTimeGetCurrent() - startTime - } -} - -// MARK: - Custom Components Demo - -struct CustomComponentsDemo: View { - var body: some View { - ScrollView { - VStack(spacing: 24.h) { - Text("Custom Components") - .font(.system(size: (24).sp, weight: .bold)) - - ResponsiveProfileCard() - - ResponsiveProgressBar() - - ResponsiveStatsView() - - Spacer(minLength: 40.h) - } - .padding(.horizontal, 20.w) - } - .navigationTitle("Custom Components") - .navigationBarTitleDisplayMode(.inline) - } -} - -struct ResponsiveProfileCard: View { - var body: some View { - VStack(spacing: 16.h) { - Text("Profile Card") - .font(.system(size: (18).sp, weight: .semibold)) - - HStack(spacing: 16.w) { - Circle() - .fill( - LinearGradient( - colors: [.blue, .purple], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 60.w, height: 60.h) - .overlay( - Text("JD") - .font(.system(size: (20).sp, weight: .bold)) - .foregroundColor(.white) - ) - - VStack(alignment: .leading, spacing: 4.h) { - Text("John Doe") - .font(.system(size: (16).sp, weight: .semibold)) - - Text("iOS Developer") - .font(.system(size: (14).sp)) - .foregroundColor(.secondary) - - HStack(spacing: 4.w) { - Image(systemName: "location.fill") - .font(.system(size: 10.sp)) - Text("San Francisco, CA") - .font(.system(size: (12).sp)) - } - .foregroundColor(.secondary) - } - - Spacer() - } - .padding(20.w) - .background( - RoundedRectangle(cornerRadius: 16.r) - .fill(Color(.secondarySystemBackground)) - .shadow(color: .black.opacity(0.1), radius: 8.r, y: 4.h) - ) - } - } -} - -struct ResponsiveProgressBar: View { - @State private var progress: Double = 0.7 - - var body: some View { - VStack(spacing: 16.h) { - Text("Progress Bar") - .font(.system(size: (18).sp, weight: .semibold)) - - VStack(alignment: .leading, spacing: 8.h) { - HStack { - Text("Project Progress") - .font(.system(size: (14).sp, weight: .medium)) - - Spacer() - - Text("\(Int(progress * 100))%") - .font(.system(size: (12).sp, weight: .medium)) - .foregroundColor(.blue) - } - - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4.r) - .fill(Color.gray.opacity(0.2)) - .frame(height: 8.h) - - RoundedRectangle(cornerRadius: 4.r) - .fill( - LinearGradient( - colors: [.blue, .purple], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: geometry.size.width * progress, height: 8.h) - .animation(.easeInOut(duration: 0.5), value: progress) - } - } - .frame(height: 8.h) - } - .padding(16.w) - .background( - RoundedRectangle(cornerRadius: 12.r) - .fill(Color(.secondarySystemBackground)) - ) - - Button("Update Progress") { - progress = Double.random(in: 0.3...1.0) - } - .font(.system(size: (14).sp, weight: .medium)) - } - } -} - -struct ResponsiveStatsView: View { - let stats = [ - ("Projects", "24", Color.blue), - ("Tasks", "156", Color.green), - ("Hours", "1.2K", Color.orange), - ("Reviews", "98%", Color.purple) - ] - - var body: some View { - VStack(spacing: 16.h) { - Text("Statistics") - .font(.system(size: (18).sp, weight: .semibold)) - - LazyVGrid(columns: [ - GridItem(.flexible(), spacing: 8.w), - GridItem(.flexible(), spacing: 8.w) - ], spacing: 12.h) { - ForEach(stats.indices, id: \.self) { index in - let (title, value, color) = stats[index] - - VStack(spacing: 8.h) { - Text(value) - .font(.system(size: (24).sp, weight: .bold)) - .foregroundColor(color) - - Text(title) - .font(.system(size: (12).sp)) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 20.h) - .background( - RoundedRectangle(cornerRadius: 12.r) - .fill(color.opacity(0.1)) - ) - } - } - } - } -} - -// MARK: - Custom View Modifiers - -extension View { - func responsiveCard() -> some View { - self - .padding(.horizontal, 16.w) - .padding(.vertical, 12.h) - .background( - RoundedRectangle(cornerRadius: 8.r) - .fill(Color(.secondarySystemBackground)) - .shadow(color: .black.opacity(0.1), radius: 4.r, y: 2.h) - ) - } -} - -// MARK: - App Entry Point -struct ScreenUtilExampleApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} - -struct ContentView: View { - var body: some View { - TabView { - BasicSwiftUIView() - .tabItem { - Image(systemName: "house") - Text("Basic") - } - - AdvancedSwiftUIView() - .tabItem { - Image(systemName: "gear") - Text("Advanced") - } - } - } -} - -// MARK: - Preview - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - .previewDevice("iPhone 14 Pro") - } -} diff --git a/Examples/Sources/UIKitExample/main.swift b/Examples/Sources/UIKitExample/main.swift deleted file mode 100644 index 79f2a54..0000000 --- a/Examples/Sources/UIKitExample/main.swift +++ /dev/null @@ -1,616 +0,0 @@ -// -// UIKitExample.swift -// ScreenUtil Examples -// -// Comprehensive UIKit usage examples -// - -import UIKit -import ScreenUtil - -// MARK: - Basic UIKit Example - -class BasicUIKitViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - configureScreenUtil() - } - - private func configureScreenUtil() { - // Configure ScreenUtil with design size (e.g., iPhone 13 Pro) - ScreenUtil.shared.configure(with: .iPhone13Pro) - - // Optional: Use custom configuration - // let customConfig = ScreenUtilConfiguration( - // designSize: CGSize(width: 390, height: 844), - // minTextAdapt: true, - // scalingLimits: .default - // ) - // ScreenUtil.shared.configure(with: customConfig) - } - - private func setupUI() { - view.backgroundColor = .systemBackground - - // MARK: - 1. Basic Scaling Examples - - // Create views with responsive sizing - let headerView = createHeaderView() - let cardView = createCardView() - let buttonStackView = createButtonStack() - let infoLabel = createInfoLabel() - - // Add to view hierarchy - [headerView, cardView, buttonStackView, infoLabel].forEach { - view.addSubview($0) - $0.translatesAutoresizingMaskIntoConstraints = false - } - - // MARK: - 2. Responsive Constraints - NSLayoutConstraint.activate([ - // Header view - responsive positioning - headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24.h), - headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20.w), - headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20.w), - headerView.heightAnchor.constraint(equalToConstant: 80.h), - - // Card view - centered with responsive size - cardView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 32.h), - cardView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - cardView.widthAnchor.constraint(equalToConstant: 320.w), - cardView.heightAnchor.constraint(equalToConstant: 200.h), - - // Button stack - responsive spacing - buttonStackView.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 24.h), - buttonStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20.w), - buttonStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20.w), - buttonStackView.heightAnchor.constraint(equalToConstant: 44.h), - - // Info label - bottom positioning - infoLabel.topAnchor.constraint(equalTo: buttonStackView.bottomAnchor, constant: 32.h), - infoLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20.w), - infoLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20.w), - ]) - } - - // MARK: - UI Component Creation - - private func createHeaderView() -> UIView { - let container = UIView() - container.backgroundColor = .systemBlue - container.layer.cornerRadius = 12.r - - let titleLabel = UILabel() - titleLabel.text = "ScreenUtil UIKit Demo" - titleLabel.textColor = .white - titleLabel.font = .systemFont(ofSize: 24.sp, weight: .bold) - titleLabel.textAlignment = .center - - container.addSubview(titleLabel) - titleLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - titleLabel.centerXAnchor.constraint(equalTo: container.centerXAnchor), - titleLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor) - ]) - - return container - } - - private func createCardView() -> UIView { - let card = UIView() - card.backgroundColor = .secondarySystemBackground - card.layer.cornerRadius = 16.r - card.layer.shadowOffset = CGSize(width: 0, height: 2.h) - card.layer.shadowRadius = 8.r - card.layer.shadowOpacity = 0.1 - - // Card content - let iconView = UIImageView() - iconView.image = UIImage(systemName: "star.fill") - iconView.tintColor = .systemYellow - iconView.contentMode = .scaleAspectFit - - let titleLabel = UILabel() - titleLabel.text = "Responsive Design" - titleLabel.font = .systemFont(ofSize: 18.sp, weight: .semibold) - titleLabel.textColor = .label - - let subtitleLabel = UILabel() - subtitleLabel.text = "Automatically adapts to different screen sizes" - subtitleLabel.font = .systemFont(ofSize: 14.sp) - subtitleLabel.textColor = .secondaryLabel - subtitleLabel.numberOfLines = 0 - - [iconView, titleLabel, subtitleLabel].forEach { - card.addSubview($0) - $0.translatesAutoresizingMaskIntoConstraints = false - } - - NSLayoutConstraint.activate([ - iconView.topAnchor.constraint(equalTo: card.topAnchor, constant: 20.h), - iconView.centerXAnchor.constraint(equalTo: card.centerXAnchor), - iconView.widthAnchor.constraint(equalToConstant: 40.w), - iconView.heightAnchor.constraint(equalToConstant: 40.h), - - titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 16.h), - titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 20.w), - titleLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -20.w), - - subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8.h), - subtitleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 20.w), - subtitleLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -20.w), - ]) - - return card - } - - private func createButtonStack() -> UIStackView { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fillEqually - stackView.spacing = 16.w - - let primaryButton = createButton(title: "Primary", style: .primary) - let secondaryButton = createButton(title: "Secondary", style: .secondary) - - stackView.addArrangedSubview(primaryButton) - stackView.addArrangedSubview(secondaryButton) - - return stackView - } - - private enum ButtonStyle { - case primary, secondary - } - - private func createButton(title: String, style: ButtonStyle) -> UIButton { - let button = UIButton(type: .system) - button.setTitle(title, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 16.sp, weight: .medium) - button.layer.cornerRadius = 8.r - - switch style { - case .primary: - button.backgroundColor = .systemBlue - button.setTitleColor(.white, for: .normal) - case .secondary: - button.backgroundColor = .clear - button.setTitleColor(.systemBlue, for: .normal) - button.layer.borderWidth = 1.w - button.layer.borderColor = UIColor.systemBlue.cgColor - } - - // Add target for demonstration - button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside) - - return button - } - - private func createInfoLabel() -> UILabel { - let label = UILabel() - label.numberOfLines = 0 - label.font = .systemFont(ofSize: 12.sp) - label.textColor = .secondaryLabel - - // Demonstrate screen metrics access - let metrics = ScreenUtil.shared.getScreenMetrics() - label.text = """ - Screen Metrics: - • Size: \(Int(metrics.width)) x \(Int(metrics.height)) pts - • Scale: \(metrics.scale)x - • Safe Area: T:\(Int(metrics.safeAreaInsets.top)) B:\(Int(metrics.safeAreaInsets.bottom)) - • Device: \(ScreenUtil.shared.deviceType) - """ - - return label - } - - @objc private func buttonTapped(_ sender: UIButton) { - // Demonstrate debug functionality - ScreenUtilDebug.printCurrentConfiguration() - - // Show alert with responsive sizing - let alert = UIAlertController( - title: "Button Tapped", - message: "This alert demonstrates responsive design!", - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - } -} - -// MARK: - Advanced UIKit Examples - -class AdvancedUIKitViewController: UIViewController { - - private let scrollView = UIScrollView() - private let contentView = UIView() - - override func viewDidLoad() { - super.viewDidLoad() - setupScrollView() - setupAdvancedExamples() - } - - private func setupScrollView() { - view.backgroundColor = .systemBackground - - view.addSubview(scrollView) - scrollView.addSubview(contentView) - - scrollView.translatesAutoresizingMaskIntoConstraints = false - contentView.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), - contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), - contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) - ]) - } - - private func setupAdvancedExamples() { - // MARK: - 1. Batch Operations Example - createBatchOperationsExample() - - // MARK: - 2. Fast Scaling Example - createFastScalingExample() - - // MARK: - 3. Custom Scaling Limits Example - createScalingLimitsExample() - - // MARK: - 4. Performance Demonstration - createPerformanceExample() - } - - private func createBatchOperationsExample() { - let sectionView = createSectionView(title: "Batch Operations") - - // Demonstrate batch scaling for multiple values - let values = [10, 20, 30, 40, 50] - let scaledWidths = ScreenUtil.shared.batchScaler.widths(values) - let scaledHeights = ScreenUtil.shared.batchScaler.heights(values) - - // Create visual representation - let containerView = UIView() - containerView.backgroundColor = .systemGray6 - containerView.layer.cornerRadius = 8.r - - for (index, _) in values.enumerated() { - let barView = UIView() - barView.backgroundColor = .systemBlue - barView.layer.cornerRadius = 2.r - - containerView.addSubview(barView) - barView.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - barView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: (20.w + CGFloat(index) * 60.w)), - barView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -10.h), - barView.widthAnchor.constraint(equalToConstant: scaledWidths[index]), - barView.heightAnchor.constraint(equalToConstant: scaledHeights[index]) - ]) - } - - sectionView.addSubview(containerView) - containerView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - containerView.topAnchor.constraint(equalTo: sectionView.subviews.first!.bottomAnchor, constant: 16.h), - containerView.leadingAnchor.constraint(equalTo: sectionView.leadingAnchor, constant: 20.w), - containerView.trailingAnchor.constraint(equalTo: sectionView.trailingAnchor, constant: -20.w), - containerView.heightAnchor.constraint(equalToConstant: 100.h), - containerView.bottomAnchor.constraint(equalTo: sectionView.bottomAnchor, constant: -20.h) - ]) - - addSectionToContent(sectionView) - } - - private func createFastScalingExample() { - let sectionView = createSectionView(title: "Fast Scaling") - - // Demonstrate fast scaling operations - let fastScale = ScreenUtil.shared.fastScale - - let gridContainer = UIView() - gridContainer.backgroundColor = .systemGray6 - gridContainer.layer.cornerRadius = 8.r - - // Create a grid of squares with fast scaling - let gridSize = 4 - for row in 0.. UIView { - let sectionView = UIView() - - let titleLabel = UILabel() - titleLabel.text = title - titleLabel.font = .systemFont(ofSize: 18.sp, weight: .semibold) - titleLabel.textColor = .label - - sectionView.addSubview(titleLabel) - titleLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: sectionView.topAnchor, constant: 20.h), - titleLabel.leadingAnchor.constraint(equalTo: sectionView.leadingAnchor, constant: 20.w), - titleLabel.trailingAnchor.constraint(equalTo: sectionView.trailingAnchor, constant: -20.w) - ]) - - return sectionView - } - - private var lastSectionView: UIView? - - private func addSectionToContent(_ sectionView: UIView) { - contentView.addSubview(sectionView) - sectionView.translatesAutoresizingMaskIntoConstraints = false - - let topConstraint: NSLayoutConstraint - if let lastSection = lastSectionView { - topConstraint = sectionView.topAnchor.constraint(equalTo: lastSection.bottomAnchor, constant: 32.h) - } else { - topConstraint = sectionView.topAnchor.constraint(equalTo: contentView.topAnchor) - } - - NSLayoutConstraint.activate([ - topConstraint, - sectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - sectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) - ]) - - // Update content view bottom constraint - if let lastSection = lastSectionView { - lastSection.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = false - } - sectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20.h).isActive = true - - lastSectionView = sectionView - } -} - -// MARK: - Custom Views with ScreenUtil - -class ResponsiveCardView: UIView { - - private let imageView = UIImageView() - private let titleLabel = UILabel() - private let subtitleLabel = UILabel() - private let actionButton = UIButton(type: .system) - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupView() - } - - private func setupView() { - backgroundColor = .secondarySystemBackground - layer.cornerRadius = 16.r - layer.shadowOffset = CGSize(width: 0, height: 4.h) - layer.shadowRadius = 12.r - layer.shadowOpacity = 0.1 - - setupImageView() - setupLabels() - setupButton() - setupConstraints() - } - - private func setupImageView() { - imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true - imageView.layer.cornerRadius = 8.r - imageView.backgroundColor = .systemGray5 - } - - private func setupLabels() { - titleLabel.font = .systemFont(ofSize: 16.sp, weight: .semibold) - titleLabel.textColor = .label - titleLabel.numberOfLines = 2 - - subtitleLabel.font = .systemFont(ofSize: 14.sp) - subtitleLabel.textColor = .secondaryLabel - subtitleLabel.numberOfLines = 3 - } - - private func setupButton() { - actionButton.setTitle("Learn More", for: .normal) - actionButton.titleLabel?.font = .systemFont(ofSize: 14.sp, weight: .medium) - actionButton.backgroundColor = .systemBlue - actionButton.setTitleColor(.white, for: .normal) - actionButton.layer.cornerRadius = 6.r - } - - private func setupConstraints() { - [imageView, titleLabel, subtitleLabel, actionButton].forEach { - addSubview($0) - $0.translatesAutoresizingMaskIntoConstraints = false - } - - NSLayoutConstraint.activate([ - // Image view - imageView.topAnchor.constraint(equalTo: topAnchor, constant: 16.h), - imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16.w), - imageView.widthAnchor.constraint(equalToConstant: 60.w), - imageView.heightAnchor.constraint(equalToConstant: 60.h), - - // Title label - titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16.h), - titleLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 12.w), - titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16.w), - - // Subtitle label - subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4.h), - subtitleLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 12.w), - subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16.w), - - // Action button - actionButton.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 16.h), - actionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16.w), - actionButton.widthAnchor.constraint(equalToConstant: 100.w), - actionButton.heightAnchor.constraint(equalToConstant: 32.h), - actionButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16.h) - ]) - } - - func configure(title: String, subtitle: String, image: UIImage?) { - titleLabel.text = title - subtitleLabel.text = subtitle - imageView.image = image - } -} diff --git a/Examples/SwiftUIDemo/DemoApp.swift b/Examples/SwiftUIDemo/DemoApp.swift new file mode 100644 index 0000000..a246447 --- /dev/null +++ b/Examples/SwiftUIDemo/DemoApp.swift @@ -0,0 +1,24 @@ +// +// DemoApp.swift +// ScreenUtil Examples +// +// SwiftUI showcase entry point — configures the ScreenUtil scale baseline at launch. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import SwiftUI + +@main +struct DemoApp: App { + init() { + // Design baseline: iPhone 12 (390×844). iPhone 12 renders 1:1; larger devices scale up. + // A single configure() suffices: ScreenUtil's scene-activate observer rebuilds the + // metrics snapshot once the window exists, and ProfileViewModel publishes it after load. + ScreenUtil.shared.configure(with: .iPhone12) + } + + var body: some Scene { + WindowGroup { ProfileView() } + } +} diff --git a/Examples/SwiftUIDemo/ProfileView.swift b/Examples/SwiftUIDemo/ProfileView.swift new file mode 100644 index 0000000..c291c9e --- /dev/null +++ b/Examples/SwiftUIDemo/ProfileView.swift @@ -0,0 +1,72 @@ +// +// ProfileView.swift +// ScreenUtil Examples +// +// The single scaled profile page — renders per LoadState; every dimension scales. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import SwiftUI + +struct ProfileView: View { + @State private var model: ProfileViewModel + + /// Repository is injected (Dependency Inversion); defaults to the live one. + /// `State(wrappedValue:)` is the correct way to seed view-owned @State from init. + init(repository: ProfileRepository = + LiveProfileRepository(username: ExampleProfile.username)) { + _model = State(wrappedValue: ProfileViewModel(repository: repository)) + } + + var body: some View { + Group { + switch model.state { + case .idle, .loading: + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityLabel("Loading profile") + case .failed(let error): + ContentUnavailableView { + Label("Couldn't Load Profile", systemImage: "wifi.slash") + } description: { + Text(Self.message(for: error)) + } actions: { + Button("Retry") { Task { await model.retry() } } + } + case .loaded(let profile): + loadedContent(profile) + } + } + .task { await model.load() } + } + + private func loadedContent(_ profile: Profile) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20.h) { + ProfileHeader(profile: profile) + StatsRow(profile: profile) + Highlights() + .padding(.horizontal, -20.w) // full-bleed: opt out of the page padding + TagChips(tags: ExampleProfile.tags) + DeviceCard(metrics: model.metrics) + } + .padding(.horizontal, 20.w) + .padding(.top, 12.h) + } + } + + private static func message(for error: Error) -> String { + (error as? URLError)?.localizedDescription + ?? "Please check your connection and try again." + } +} + +#Preview("Loaded") { + ProfileView(repository: StubProfileRepository { .preview }) +} + +#Preview("Failed") { + ProfileView(repository: StubProfileRepository { throw URLError(.notConnectedToInternet) }) +} diff --git a/Examples/SwiftUIDemo/Sections/DeviceCard.swift b/Examples/SwiftUIDemo/Sections/DeviceCard.swift new file mode 100644 index 0000000..b216875 --- /dev/null +++ b/Examples/SwiftUIDemo/Sections/DeviceCard.swift @@ -0,0 +1,87 @@ +// +// DeviceCard.swift +// ScreenUtil Examples +// +// "Device & scaling" card — surfaces metrics + the explicit scaling entry points. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import SwiftUI + +struct DeviceCard: View { + // Driving the card off observable state (passed in) is what makes SwiftUI re-render it + // when metrics change — reading ScreenUtil imperatively alone would never invalidate the view. + let metrics: ScreenMetrics + + // Read ScreenUtil from the environment (default value is `.shared`). + @Environment(\.screenUtil) private var screenUtil + + var body: some View { + VStack(alignment: .leading, spacing: 8.h) { + Text("Device & Scaling") + .font(.system(size: 17.sp, weight: .semibold)) + + row("device", "\(screenUtil.deviceType)") + row("screen", Format.describe(CGSize(width: metrics.width, height: metrics.height))) + row("scaleW · scaleH", "\(Format.twoDecimals(screenUtil.scaleWidth)) · \(Format.twoDecimals(screenUtil.scaleHeight))") + row("scaleText", Format.twoDecimals(screenUtil.scaleText)) + row("safeArea T/B", "\(Format.integer(metrics.safeAreaInsets.top)) / \(Format.integer(metrics.safeAreaInsets.bottom))") + row("safeArea L/R", "\(Format.integer(metrics.safeAreaInsets.left)) / \(Format.integer(metrics.safeAreaInsets.right))") + row("statusBar", Format.integer(metrics.statusBarHeight)) + row("screen W·H", "\(Format.integer(metrics.width)) · \(Format.integer(metrics.height))") + // Explicit scaling entry points (numeric sugar wraps these) — all four ScaleType cases. + row("scale(100,.width)", Format.integer(screenUtil.scale(for: 100, scaleType: .width))) + row("scale(100,.height)", Format.integer(screenUtil.scale(for: 100, scaleType: .height))) + row("scale(16,.radius)", Format.integer(screenUtil.scale(for: 16, scaleType: .radius))) + row("fastScale(100,.text)", Format.integer(screenUtil.fastScale(for: 100, scaleType: .text))) + // Honest self-test exercising the bulk + per-call geometry APIs that have no natural + // home in declarative layout (kept to one labelled line, not faked into the UI). + row("bulk self-test", selfTest()) + } + .padding(16.w) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary, in: .rect(cornerRadius: 16.r)) + } + + private func row(_ key: String, _ value: String) -> some View { + HStack { + Text(key).foregroundStyle(.secondary) + Spacer() + Text(value).fontWeight(.medium) + } + .font(.system(size: 13.sp)) + } + + /// Exercises every bulk/geometry API that has no natural home in declarative SwiftUI layout, + /// so the showcase still demonstrates 100% of the public surface. One line, honestly labelled. + private func selfTest() -> String { + let batch = ScreenUtil.shared.batchScaler + let widths = batch.widths([10, 20, 30]) + let heights = batch.heights([10, 20]) + let fonts = batch.fontSizes([12, 14]) + let radii = batch.radii([8, 12]) + let sizes = batch.sizes([CGSize(width: 10, height: 10)]) + let points = batch.points([CGPoint(x: 10, y: 10), CGPoint(x: 20, y: 20)]) + let rects = batch.rects([CGRect(x: 0, y: 0, width: 50, height: 50)]) + let byHeight = batch.scale([100, 200], scaleType: .height) + + let fast = ScreenUtil.shared.fastScale + _ = fast.size(CGSize(width: 10, height: 10)) + _ = fast.point(CGPoint(x: 5, y: 5)) + _ = fast.rect(CGRect(x: 0, y: 0, width: 8, height: 8)) + _ = withFastScale { $0.width(20) } + + // CGGeometry helpers (static + instance forms). + _ = CGSize.scaled(width: 8, height: 8) + _ = CGSize(width: 8, height: 8).fastScaled() + _ = CGRect.scaled(x: 0, y: 0, width: 10, height: 10) + _ = CGRect.responsive(x: 0, y: 0, width: 10, height: 10) + _ = CGPoint.responsive(x: 4, y: 4) + _ = CGPoint(x: 4, y: 4).fastScaled() + + let count = widths.count + heights.count + fonts.count + radii.count + + sizes.count + points.count + rects.count + byHeight.count + return "\(count) bulk values ✓" + } +} diff --git a/Examples/SwiftUIDemo/Sections/Highlights.swift b/Examples/SwiftUIDemo/Sections/Highlights.swift new file mode 100644 index 0000000..fe614b6 --- /dev/null +++ b/Examples/SwiftUIDemo/Sections/Highlights.swift @@ -0,0 +1,38 @@ +// +// Highlights.swift +// ScreenUtil Examples +// +// Horizontal "highlights" strip built in a loop with a captured FastScale. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import SwiftUI + +struct Highlights: View { + private let titles = ["Swift", "iOS", "UIKit", "SwiftUI", "Perf", "Open"] + + var body: some View { + // Capture scale factors once for the repeated circles (hot path). + let fast = ScreenUtil.shared.fastScale + let diameter = fast.width(64) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: fast.width(14)) { + ForEach(titles, id: \.self) { title in + VStack(spacing: fast.height(6)) { + Circle() + .fill(Color.accentColor.gradient) + .frame(width: diameter, height: diameter) + .overlay(Text(title.prefix(1)).font(.system(size: fast.text(24), weight: .bold)).foregroundStyle(.white)) + Text(title).font(.system(size: fast.text(12))) + } + } + } + } + // Full-bleed carousel: the caller cancels the page's 20pt padding so the + // scroll track reaches the screen edges; this re-insets the *content* by + // the same 20pt, so the first circle still lines up with the other rows + // and items scroll edge-to-edge instead of being clipped by the padding. + .contentMargins(.horizontal, 20.w, for: .scrollContent) + } +} diff --git a/Examples/SwiftUIDemo/Sections/ProfileHeader.swift b/Examples/SwiftUIDemo/Sections/ProfileHeader.swift new file mode 100644 index 0000000..6054d05 --- /dev/null +++ b/Examples/SwiftUIDemo/Sections/ProfileHeader.swift @@ -0,0 +1,51 @@ +// +// ProfileHeader.swift +// ScreenUtil Examples +// +// Banner + avatar + identity. All sizes scale from the design baseline. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import SwiftUI + +struct ProfileHeader: View { + let profile: Profile + + // Avatar diameter expressed as a scaled design size (square). + private var avatarSize: CGSize { CGSize.scaled(width: 96, height: 96) } + + var body: some View { + VStack(alignment: .leading, spacing: 12.h) { + ZStack(alignment: .bottomLeading) { + LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) + .frame(height: 140.h) + .clipShape(.rect(cornerRadius: 20.r)) + + AsyncImage(url: profile.avatar) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.gray.opacity(0.3) + } + .frame(width: avatarSize.width, height: avatarSize.height) + .clipShape(.circle) + .overlay(Circle().strokeBorder(.background, lineWidth: 4.w)) + .offset(x: 20.w, y: avatarSize.height / 2) + } + .padding(.bottom, avatarSize.height / 2) + + VStack(alignment: .leading, spacing: 4.h) { + Text(profile.name) + .font(.system(size: 24.sp, weight: .bold)) + Text("@\(profile.username)") + .font(.system(size: 15.sp)) + .foregroundStyle(.secondary) + if let bio = profile.bio { + Text(bio) + .font(.system(size: 15.sp)) + .padding(.top, 4.h) + } + } + } + } +} diff --git a/Examples/SwiftUIDemo/Sections/StatsRow.swift b/Examples/SwiftUIDemo/Sections/StatsRow.swift new file mode 100644 index 0000000..b2eff8d --- /dev/null +++ b/Examples/SwiftUIDemo/Sections/StatsRow.swift @@ -0,0 +1,45 @@ +// +// StatsRow.swift +// ScreenUtil Examples +// +// Repos / followers / following, sized in bulk via BatchScaler. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import SwiftUI + +struct StatsRow: View { + let profile: Profile + + // `label` is unique per row, so it doubles as a stable ForEach identity. + private struct Stat: Identifiable { let value: Int; let label: String; var id: String { label } } + private var stats: [Stat] { + [.init(value: profile.repos, label: "Repos"), + .init(value: profile.followers, label: "Followers"), + .init(value: profile.following, label: "Following")] + } + + var body: some View { + // Bulk-scale the two font sizes once, then reuse per column. + let sizes = ScreenUtil.shared.batchScaler.fontSizes([22, 13]) + HStack { + ForEach(stats) { stat in + VStack(spacing: 2.h) { + Text(Self.compact(stat.value)) + .font(.system(size: sizes[0], weight: .bold)) + Text(stat.label) + .font(.system(size: sizes[1])) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 14.h) + .background(.quaternary, in: .rect(cornerRadius: 16.r)) + } + + private static func compact(_ n: Int) -> String { + n >= 1000 ? String(format: "%.1fk", Double(n) / 1000) : "\(n)" + } +} diff --git a/Examples/SwiftUIDemo/Sections/TagChips.swift b/Examples/SwiftUIDemo/Sections/TagChips.swift new file mode 100644 index 0000000..e878265 --- /dev/null +++ b/Examples/SwiftUIDemo/Sections/TagChips.swift @@ -0,0 +1,30 @@ +// +// TagChips.swift +// ScreenUtil Examples +// +// Topic chips whose widths/corner radii are batch-scaled. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import SwiftUI + +struct TagChips: View { + let tags: [String] + + var body: some View { + // Bulk example: scale a uniform corner radius + min width for every chip. + let radii = ScreenUtil.shared.batchScaler.radii(Array(repeating: 12, count: tags.count)) + let widths = withBatchScaler { $0.widths(Array(repeating: 64, count: tags.count)) } + HStack { + ForEach(Array(tags.enumerated()), id: \.offset) { index, tag in + Text(tag) + .font(.system(size: 13.sp, weight: .medium)) + .padding(.horizontal, 12.w) + .padding(.vertical, 6.h) + .frame(minWidth: widths[index]) + .background(.tint.opacity(0.15), in: .rect(cornerRadius: radii[index])) + } + } + } +} diff --git a/Examples/Tests/GitHubUserMappingTests.swift b/Examples/Tests/GitHubUserMappingTests.swift new file mode 100644 index 0000000..72a87c3 --- /dev/null +++ b/Examples/Tests/GitHubUserMappingTests.swift @@ -0,0 +1,33 @@ +// +// GitHubUserMappingTests.swift +// ScreenUtil Examples +// +// Created by Dicky Darmawan on 27/06/26. +// + +import XCTest + +final class GitHubUserMappingTests: XCTestCase { + private func makeUser(name: String?) -> GitHubUser { + GitHubUser( + login: "octocat", name: name, bio: "bio", + avatarURL: URL(string: "https://example.com/a.png")!, + publicRepos: 3, followers: 10, following: 2, + location: nil, company: nil, blog: nil + ) + } + + func testMapsFieldsToDomain() { + let profile = makeUser(name: "The Octocat").toDomain() + XCTAssertEqual(profile.username, "octocat") + XCTAssertEqual(profile.name, "The Octocat") + XCTAssertEqual(profile.bio, "bio") + XCTAssertEqual(profile.repos, 3) + XCTAssertEqual(profile.followers, 10) + XCTAssertEqual(profile.following, 2) + } + + func testNameFallsBackToLogin() { + XCTAssertEqual(makeUser(name: nil).toDomain().name, "octocat") + } +} diff --git a/Examples/Tests/LoadStateTests.swift b/Examples/Tests/LoadStateTests.swift new file mode 100644 index 0000000..13ed63b --- /dev/null +++ b/Examples/Tests/LoadStateTests.swift @@ -0,0 +1,20 @@ +// +// LoadStateTests.swift +// ScreenUtil Examples +// +// Created by Dicky Darmawan on 27/06/26. +// + +import XCTest + +final class LoadStateTests: XCTestCase { + func testValueIsNilUntilLoaded() { + XCTAssertNil(LoadState.idle.value) + XCTAssertNil(LoadState.loading.value) + XCTAssertNil(LoadState.failed(URLError(.badURL)).value) + } + + func testValueReturnsLoadedPayload() { + XCTAssertEqual(LoadState.loaded(Profile.preview).value, .preview) + } +} diff --git a/Examples/Tests/ProfileRepositoryTests.swift b/Examples/Tests/ProfileRepositoryTests.swift new file mode 100644 index 0000000..ce76a8b --- /dev/null +++ b/Examples/Tests/ProfileRepositoryTests.swift @@ -0,0 +1,26 @@ +// +// ProfileRepositoryTests.swift +// ScreenUtil Examples +// +// Created by Dicky Darmawan on 27/06/26. +// + +import XCTest + +final class ProfileRepositoryTests: XCTestCase { + func testStubReturnsConfiguredProfile() async throws { + let repo = StubProfileRepository { .preview } + let profile = try await repo.fetch() + XCTAssertEqual(profile, .preview) + } + + func testStubThrowsConfiguredError() async { + let repo = StubProfileRepository { throw URLError(.notConnectedToInternet) } + do { + _ = try await repo.fetch() + XCTFail("expected fetch to throw") + } catch { + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + } +} diff --git a/Examples/Tests/ProfileViewModelTests.swift b/Examples/Tests/ProfileViewModelTests.swift new file mode 100644 index 0000000..5a07630 --- /dev/null +++ b/Examples/Tests/ProfileViewModelTests.swift @@ -0,0 +1,31 @@ +// +// ProfileViewModelTests.swift +// ScreenUtil Examples +// +// Created by Dicky Darmawan on 27/06/26. +// + +import XCTest + +@MainActor +final class ProfileViewModelTests: XCTestCase { + func testLoadSuccessEndsLoaded() async { + let model = ProfileViewModel(repository: StubProfileRepository { .preview }) + await model.load() + XCTAssertEqual(model.state.value, .preview) + } + + func testLoadFailureEndsFailed() async { + let model = ProfileViewModel(repository: StubProfileRepository { throw URLError(.timedOut) }) + await model.load() + guard case .failed = model.state else { + return XCTFail("expected .failed, got \(model.state)") + } + } + + func testRetryReloads() async { + let model = ProfileViewModel(repository: StubProfileRepository { .preview }) + await model.retry() + XCTAssertEqual(model.state.value, .preview) + } +} diff --git a/Examples/UIKitDemo/AppDelegate.swift b/Examples/UIKitDemo/AppDelegate.swift new file mode 100644 index 0000000..a01b935 --- /dev/null +++ b/Examples/UIKitDemo/AppDelegate.swift @@ -0,0 +1,35 @@ +// +// AppDelegate.swift +// ScreenUtil Examples +// +// UIKit demo entry point. +// Created by Dicky Darmawan on 21/06/26. +// + +import ScreenUtil +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + // Design baseline: iPhone 12 (390×844). On iPhone 12 every value renders 1:1; + // on larger devices (e.g. iPhone 17 Pro Max) the whole UI scales up proportionally. + // A single configure() suffices: ScreenUtil's scene-activate observer rebuilds the + // metrics snapshot once the window exists, and loadUser rebuilds the sections after. + ScreenUtil.shared.configure(with: .iPhone12) + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } +} diff --git a/Examples/UIKitDemo/ProfileViewController.swift b/Examples/UIKitDemo/ProfileViewController.swift new file mode 100644 index 0000000..f9e6146 --- /dev/null +++ b/Examples/UIKitDemo/ProfileViewController.swift @@ -0,0 +1,140 @@ +// +// ProfileViewController.swift +// ScreenUtil Examples +// +// The single scaled profile page (UIKit) — renders per LoadState; sizes scale. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +final class ProfileViewController: UIViewController { + // Same view model as the SwiftUI demo (Shared/). UIKit drives render() imperatively. + private let model = ProfileViewModel( + repository: LiveProfileRepository(username: ExampleProfile.username) + ) + private var loadTask: Task? + private var didApplyContainerMetrics = false + + private let spinner: UIActivityIndicatorView = { + let spinner = UIActivityIndicatorView(style: .large) + spinner.hidesWhenStopped = false + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.isAccessibilityElement = true + spinner.accessibilityLabel = "Loading profile" + return spinner + }() + + private lazy var errorView: ErrorStateView = { + let view = ErrorStateView { [weak self] in self?.startLoad() } + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = true + return scrollView + }() + + private let contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .fill + stack.translatesAutoresizingMaskIntoConstraints = false + return stack + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + contentStack.isLayoutMarginsRelativeArrangement = true + + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + view.addSubview(spinner) + view.addSubview(errorView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor), + errorView.topAnchor.constraint(equalTo: view.topAnchor), + errorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + errorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + errorView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + render() + startLoad() + } + + /// Scaled container geometry needs valid ScreenUtil metrics, which only exist + /// once the window does — so apply it in viewIsAppearing, not viewDidLoad. + override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + guard !didApplyContainerMetrics else { return } + didApplyContainerMetrics = true + contentStack.spacing = 20.h + contentStack.layoutMargins = .scaled(horizontal: 20, vertical: 12) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + loadTask?.cancel() + } + + private func startLoad() { + loadTask?.cancel() + loadTask = Task { [weak self] in + await self?.model.load() + guard let self, !Task.isCancelled else { return } + self.render() + } + } + + private func render() { + switch model.state { + case .idle, .loading: + spinner.startAnimating() + spinner.isHidden = false + errorView.isHidden = true + scrollView.isHidden = true + case .failed(let error): + spinner.stopAnimating() + spinner.isHidden = true + errorView.isHidden = false + scrollView.isHidden = true + errorView.configure(message: Self.message(for: error)) + case .loaded(let profile): + spinner.stopAnimating() + spinner.isHidden = true + errorView.isHidden = true + scrollView.isHidden = false + rebuildSections(profile) + } + } + + private func rebuildSections(_ profile: Profile) { + contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + contentStack.addArrangedSubview(ProfileHeaderView(profile: profile)) + contentStack.addArrangedSubview(StatsRowView(profile: profile)) + contentStack.addArrangedSubview(HighlightsStripView()) + contentStack.addArrangedSubview(TagChipsView(tags: ExampleProfile.tags)) + contentStack.addArrangedSubview(DeviceCardView()) + } + + private static func message(for error: Error) -> String { + (error as? URLError)?.localizedDescription + ?? "Please check your connection and try again." + } +} diff --git a/Examples/UIKitDemo/SceneDelegate.swift b/Examples/UIKitDemo/SceneDelegate.swift new file mode 100644 index 0000000..fa4c231 --- /dev/null +++ b/Examples/UIKitDemo/SceneDelegate.swift @@ -0,0 +1,29 @@ +// +// SceneDelegate.swift +// ScreenUtil Examples +// +// Scene setup: installs the root profile screen. +// Created by Dicky Darmawan on 21/06/26. +// + +import UIKit + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + let window = UIWindow(windowScene: windowScene) + // No navigation controller: the profile header already shows the identity, so + // the demo matches the SwiftUI version (no title bar). The scroll view insets + // its content below the status bar automatically via the safe area. + window.rootViewController = ProfileViewController() + self.window = window + window.makeKeyAndVisible() + } +} diff --git a/Examples/UIKitDemo/Views/DeviceCardView.swift b/Examples/UIKitDemo/Views/DeviceCardView.swift new file mode 100644 index 0000000..b36813f --- /dev/null +++ b/Examples/UIKitDemo/Views/DeviceCardView.swift @@ -0,0 +1,94 @@ +// +// DeviceCardView.swift +// ScreenUtil Examples +// +// "Device & scaling" card (UIKit) — metrics + explicit scaling entry points. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +final class DeviceCardView: UIView { + init() { + super.init(frame: .zero) + backgroundColor = .secondarySystemBackground + layer.cornerRadius = 16.r + layoutMargins = .scaled(all: 16) + + let screenUtil = ScreenUtil.shared + let metrics = screenUtil.getScreenMetrics() + let rows: [(String, String)] = [ + ("device", "\(screenUtil.deviceType)"), + ("screen", Format.describe(CGSize(width: metrics.width, height: metrics.height))), + ("scaleW · scaleH", "\(Format.twoDecimals(screenUtil.scaleWidth)) · \(Format.twoDecimals(screenUtil.scaleHeight))"), + ("scaleText", Format.twoDecimals(screenUtil.scaleText)), + ("safeArea T/B", "\(Format.integer(screenUtil.safeAreaTop)) / \(Format.integer(screenUtil.safeAreaBottom))"), + ("safeArea L/R", "\(Format.integer(screenUtil.safeAreaLeft)) / \(Format.integer(screenUtil.safeAreaRight))"), + ("statusBar", Format.integer(screenUtil.statusBarHeight)), + ("screen W·H", "\(Format.integer(screenUtil.screenWidth)) · \(Format.integer(screenUtil.screenHeight))"), + // All four ScaleType cases via the explicit entry points. + ("scale(100,.width)", Format.integer(screenUtil.scale(for: 100, scaleType: .width))), + ("scale(100,.height)", Format.integer(screenUtil.scale(for: 100, scaleType: .height))), + ("scale(16,.radius)", Format.integer(screenUtil.scale(for: 16, scaleType: .radius))), + ("fastScale(100,.text)", Format.integer(screenUtil.fastScale(for: 100, scaleType: .text))), + // One labelled line for the bulk/geometry APIs without a natural UIKit home. + ("bulk self-test", Self.selfTest()), + ] + + let title = UILabel() + title.text = "Device & Scaling" + title.font = .systemFont(ofSize: 17, weight: .semibold, scaled: true) + + let stack = UIStackView(arrangedSubviews: [title] + rows.map(makeRow)) + stack.axis = .vertical + stack.spacing = 8.h + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stack.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Exercises the bulk + geometry APIs that have no natural home in this card's layout, + /// so the UIKit showcase still demonstrates 100% of the public surface. One labelled line. + private static func selfTest() -> String { + let batch = ScreenUtil.shared.batchScaler + let widths = batch.widths([10, 20]) + let heights = batch.heights([10, 20]) + let sizes = batch.sizes([CGSize(width: 8, height: 8)]) + let insets = batch.edgeInsets([UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)]) + let points = batch.points([CGPoint(x: 4, y: 4)]) + let rects = batch.rects([CGRect(x: 0, y: 0, width: 8, height: 8)]) + let byHeight = batch.scale([100, 200], scaleType: .height) + + let fast = ScreenUtil.shared.fastScale + _ = fast.point(CGPoint(x: 5, y: 5)) + _ = fast.size(CGSize(width: 8, height: 8)) + _ = fast.rect(CGRect(x: 0, y: 0, width: 8, height: 8)) + _ = withFastScale { $0.width(20) } + _ = CGRect.scaled(x: 0, y: 0, width: 10, height: 10) + _ = CGRect.responsive(x: 0, y: 0, width: 10, height: 10) + // Keeps the customFont API demoed now that the card title uses the system font. + _ = UIFont.customFont(name: "Menlo", size: 12, scaled: true) + + let count = widths.count + heights.count + sizes.count + insets.count + + points.count + rects.count + byHeight.count + return "\(count) bulk values ✓" + } + + private func makeRow(_ pair: (String, String)) -> UIStackView { + let key = UILabel(); key.text = pair.0; key.textColor = .secondaryLabel + key.font = .systemFont(ofSize: 13, scaled: true) + let value = UILabel(); value.text = pair.1; value.textAlignment = .right + value.font = .systemFont(ofSize: 13, weight: .medium, scaled: true) + let row = UIStackView(arrangedSubviews: [key, value]) + row.distribution = .equalSpacing + return row + } +} diff --git a/Examples/UIKitDemo/Views/ErrorStateView.swift b/Examples/UIKitDemo/Views/ErrorStateView.swift new file mode 100644 index 0000000..5f5dc09 --- /dev/null +++ b/Examples/UIKitDemo/Views/ErrorStateView.swift @@ -0,0 +1,55 @@ +// +// ErrorStateView.swift +// ScreenUtil Examples +// +// Failure state (UIKit): message + Retry, sizes scaled via ScreenUtil. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +final class ErrorStateView: UIView { + private let onRetry: () -> Void + + private let messageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular, scaled: true) + label.textColor = .secondaryLabel + label.textAlignment = .center + label.numberOfLines = 0 + return label + }() + + private lazy var retryButton: UIButton = { + var config = UIButton.Configuration.borderedProminent() + config.title = "Retry" + let button = UIButton(configuration: config) + button.accessibilityLabel = "Retry loading profile" + button.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + return button + }() + + init(onRetry: @escaping () -> Void) { + self.onRetry = onRetry + super.init(frame: .zero) + let stack = UIStackView(arrangedSubviews: [messageLabel, retryButton]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 12.h + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 32.w), + stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -32.w), + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(message: String) { messageLabel.text = message } + + @objc private func retryTapped() { onRetry() } +} diff --git a/Examples/UIKitDemo/Views/GradientView.swift b/Examples/UIKitDemo/Views/GradientView.swift new file mode 100644 index 0000000..34de9c4 --- /dev/null +++ b/Examples/UIKitDemo/Views/GradientView.swift @@ -0,0 +1,31 @@ +// +// GradientView.swift +// ScreenUtil Examples +// +// A UIView whose backing layer is a CAGradientLayer, so the gradient resizes with +// the view automatically (no layoutSubviews bookkeeping). UIKit's counterpart to +// SwiftUI's LinearGradient / Color.gradient. +// Created by Dicky Darmawan on 27/06/26. +// + +import UIKit + +final class GradientView: UIView { + override class var layerClass: AnyClass { CAGradientLayer.self } + private var gradient: CAGradientLayer { layer as! CAGradientLayer } + + /// `colors` resolve to CGColors at init — fine here because the demo gradients use + /// fixed system hues (matching SwiftUI's non-semantic `.blue`/`.purple`), which the + /// SwiftUI side doesn't adapt for dark mode either. ponytail: add trait re-resolve + /// only if these ever become semantic colors. + init(colors: [UIColor], + start: CGPoint = CGPoint(x: 0, y: 0), + end: CGPoint = CGPoint(x: 1, y: 1)) { + super.init(frame: .zero) + gradient.colors = colors.map(\.cgColor) + gradient.startPoint = start + gradient.endPoint = end + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/Examples/UIKitDemo/Views/HighlightsStripView.swift b/Examples/UIKitDemo/Views/HighlightsStripView.swift new file mode 100644 index 0000000..c795581 --- /dev/null +++ b/Examples/UIKitDemo/Views/HighlightsStripView.swift @@ -0,0 +1,94 @@ +// +// HighlightsStripView.swift +// ScreenUtil Examples +// +// Horizontal highlights strip (UIKit) — mirrors the SwiftUI Highlights: a gradient +// circle + caption per item, full-bleed to the screen edges. FastScale drives the +// repeated per-item sizing (hot path). +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +final class HighlightsStripView: UIView { + private let titles = ["Swift", "iOS", "UIKit", "SwiftUI", "Perf", "Open"] + private let scroll = UIScrollView() + + init() { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + scroll.showsHorizontalScrollIndicator = false + scroll.translatesAutoresizingMaskIntoConstraints = false + + // Full-bleed carousel: extend the scroll past the page's 20pt side margins to the + // screen edges, then re-inset the content by the same 20pt so the first circle lines + // up with the other rows and items scroll edge-to-edge. (Matches the page padding.) + let bleed = 20.w + scroll.contentInset = UIEdgeInsets(top: 0, left: bleed, bottom: 0, right: bleed) + + // Capture scale factors once for the repeated items (hot path). + let fast = ScreenUtil.shared.fastScale + let diameter = fast.width(64) + + let row = UIStackView(arrangedSubviews: titles.map { makeItem(title: $0, diameter: diameter, fast: fast) }) + row.axis = .horizontal + row.alignment = .top + row.spacing = fast.width(14) + row.translatesAutoresizingMaskIntoConstraints = false + + addSubview(scroll) + scroll.addSubview(row) + NSLayoutConstraint.activate([ + scroll.topAnchor.constraint(equalTo: topAnchor), + scroll.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -bleed), + scroll.trailingAnchor.constraint(equalTo: trailingAnchor, constant: bleed), + scroll.bottomAnchor.constraint(equalTo: bottomAnchor), + row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), + row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), + row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), + row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), + scroll.heightAnchor.constraint(equalTo: row.heightAnchor), + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func makeItem(title: String, diameter: CGFloat, fast: FastScale) -> UIStackView { + // Gradient circle (top-light → accent) approximating SwiftUI's subtle + // Color.accentColor.gradient. + let circle = GradientView( + colors: [UIColor.systemBlue.withAlphaComponent(0.85), .systemBlue], + start: CGPoint(x: 0.5, y: 0), + end: CGPoint(x: 0.5, y: 1) + ) + circle.translatesAutoresizingMaskIntoConstraints = false + circle.layer.cornerRadius = diameter / 2 + circle.clipsToBounds = true + + let letter = UILabel() + letter.text = String(title.prefix(1)) + letter.textColor = .white + letter.font = .systemFont(ofSize: fast.text(24), weight: .bold).scaled(false) + letter.translatesAutoresizingMaskIntoConstraints = false + circle.addSubview(letter) + + let caption = UILabel() + caption.text = title + caption.font = .systemFont(ofSize: fast.text(12)).scaled(false) + caption.textAlignment = .center + + let item = UIStackView(arrangedSubviews: [circle, caption]) + item.axis = .vertical + item.alignment = .center + item.spacing = fast.height(6) + + NSLayoutConstraint.activate([ + circle.widthAnchor.constraint(equalToConstant: diameter), + circle.heightAnchor.constraint(equalToConstant: diameter), + letter.centerXAnchor.constraint(equalTo: circle.centerXAnchor), + letter.centerYAnchor.constraint(equalTo: circle.centerYAnchor), + ]) + return item + } +} diff --git a/Examples/UIKitDemo/Views/ProfileHeaderView.swift b/Examples/UIKitDemo/Views/ProfileHeaderView.swift new file mode 100644 index 0000000..c0413de --- /dev/null +++ b/Examples/UIKitDemo/Views/ProfileHeaderView.swift @@ -0,0 +1,85 @@ +// +// ProfileHeaderView.swift +// ScreenUtil Examples +// +// Banner + avatar + identity (UIKit). All sizes scaled. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +final class ProfileHeaderView: UIView { + // Gradient banner to match the SwiftUI LinearGradient(blue → purple). + private let banner: GradientView = { + let banner = GradientView(colors: [.systemBlue, .systemPurple]) + banner.cornerRadius(20) // .r scaled + banner.translatesAutoresizingMaskIntoConstraints = false + return banner + }() + + private let avatar: UIImageView = { + let avatar = UIImageView() + avatar.backgroundColor = .secondarySystemBackground + avatar.contentMode = .scaleAspectFill + avatar.clipsToBounds = true + avatar.borderWidth(4) // .w scaled + avatar.layer.borderColor = UIColor.systemBackground.cgColor + avatar.translatesAutoresizingMaskIntoConstraints = false + return avatar + }() + + private let nameLabel = makeLabel(size: 24, weight: .bold) + private let handleLabel = makeLabel(size: 15, weight: .regular, color: .secondaryLabel) + private let bioLabel = makeLabel(size: 15, weight: .regular, lines: 0) + + init(profile: Profile) { + super.init(frame: .zero) + nameLabel.text = profile.name + handleLabel.text = "@\(profile.username)" + bioLabel.text = profile.bio + let avatarSize = CGSize.scaled(width: 96, height: 96) + avatar.layer.cornerRadius = avatarSize.width / 2 + + let identity = UIStackView(arrangedSubviews: [nameLabel, handleLabel, bioLabel]) + identity.axis = .vertical + identity.spacing = 4.h + identity.translatesAutoresizingMaskIntoConstraints = false + + addSubview(banner) + addSubview(avatar) + addSubview(identity) + avatar.size(width: 96, height: 96) // already-active scaled constraints + NSLayoutConstraint.activate([ + banner.topAnchor.constraint(equalTo: topAnchor), + banner.leadingAnchor.constraint(equalTo: leadingAnchor), + banner.trailingAnchor.constraint(equalTo: trailingAnchor), + banner.heightAnchor.constraint(equalToConstant: 140.h), + avatar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20.w), + avatar.centerYAnchor.constraint(equalTo: banner.bottomAnchor), + identity.topAnchor.constraint(equalTo: avatar.bottomAnchor, constant: 12.h), + identity.leadingAnchor.constraint(equalTo: leadingAnchor), + identity.trailingAnchor.constraint(equalTo: trailingAnchor), + identity.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + loadAvatar(from: profile.avatar) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func loadAvatar(from url: URL) { + Task { [weak self] in + guard let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { return } + self?.avatar.image = image + } + } + + private static func makeLabel(size: CGFloat, weight: UIFont.Weight, color: UIColor = .label, lines: Int = 1) -> UILabel { + let label = UILabel() + label.font = .systemFont(ofSize: size, weight: weight, scaled: true) + label.textColor = color + label.numberOfLines = lines + return label + } +} diff --git a/Examples/UIKitDemo/Views/StatsRowView.swift b/Examples/UIKitDemo/Views/StatsRowView.swift new file mode 100644 index 0000000..d01cb62 --- /dev/null +++ b/Examples/UIKitDemo/Views/StatsRowView.swift @@ -0,0 +1,56 @@ +// +// StatsRowView.swift +// ScreenUtil Examples +// +// Repos / followers / following — font sizes scaled in bulk. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +final class StatsRowView: UIView { + init(profile: Profile) { + super.init(frame: .zero) + backgroundColor = .secondarySystemBackground + layer.cornerRadius = 16.r + layoutMargins = .scaled(all: 14) // UIEdgeInsets.scaled + + let sizes = ScreenUtil.shared.batchScaler.fontSizes([22, 13]) // [value, label] + let columns = [ + column(value: Self.compact(profile.repos), label: "Repos", sizes: sizes), + column(value: Self.compact(profile.followers), label: "Followers", sizes: sizes), + column(value: Self.compact(profile.following), label: "Following", sizes: sizes), + ] + let row = UIStackView(arrangedSubviews: columns) + row.distribution = .fillEqually + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + row.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + row.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + row.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func column(value: String, label: String, sizes: [CGFloat]) -> UIStackView { + let valueLabel = UILabel(); valueLabel.text = value + valueLabel.font = .systemFont(ofSize: sizes[0], weight: .bold).scaled(false) // already scaled + valueLabel.textAlignment = .center + let nameLabel = UILabel(); nameLabel.text = label + nameLabel.font = .systemFont(ofSize: sizes[1], weight: .regular).scaled(false) + nameLabel.textColor = .secondaryLabel + nameLabel.textAlignment = .center + let stack = UIStackView(arrangedSubviews: [valueLabel, nameLabel]) + stack.axis = .vertical + stack.spacing = 2.h + return stack + } + + private static func compact(_ n: Int) -> String { + n >= 1000 ? String(format: "%.1fk", Double(n) / 1000) : "\(n)" + } +} diff --git a/Examples/UIKitDemo/Views/TagChipsView.swift b/Examples/UIKitDemo/Views/TagChipsView.swift new file mode 100644 index 0000000..13b2ba2 --- /dev/null +++ b/Examples/UIKitDemo/Views/TagChipsView.swift @@ -0,0 +1,55 @@ +// +// TagChipsView.swift +// ScreenUtil Examples +// +// Topic chips with batch-scaled min widths + corner radii. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +final class TagChipsView: UIView { + init(tags: [String]) { + super.init(frame: .zero) + let widths = withBatchScaler { $0.widths(Array(repeating: 64, count: tags.count)) } + let radii = ScreenUtil.shared.batchScaler.radii(Array(repeating: 12, count: tags.count)) + let chips = tags.enumerated().map { index, tag in chip(tag, minWidth: widths[index], radius: radii[index]) } + let row = UIStackView(arrangedSubviews: chips) + row.spacing = 8.w + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: topAnchor), + row.bottomAnchor.constraint(equalTo: bottomAnchor), + row.leadingAnchor.constraint(equalTo: leadingAnchor), + row.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func chip(_ text: String, minWidth: CGFloat, radius: CGFloat) -> UILabel { + let label = PaddingLabel() + label.text = text + label.font = .systemFont(ofSize: 13, weight: .medium, scaled: true) + label.textAlignment = .center + label.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.15) // matches SwiftUI .tint.opacity(0.15) + label.layer.cornerRadius = radius + label.clipsToBounds = true + label.textInsets = .scaled(horizontal: 12, vertical: 6) + label.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth).isActive = true + return label + } +} + +/// Minimal padded label (UIKit has no built-in inset label). +final class PaddingLabel: UILabel { + var textInsets: UIEdgeInsets = .zero + override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: textInsets)) } + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + textInsets.left + textInsets.right, + height: size.height + textInsets.top + textInsets.bottom) + } +} diff --git a/Examples/project.yml b/Examples/project.yml new file mode 100644 index 0000000..e913098 --- /dev/null +++ b/Examples/project.yml @@ -0,0 +1,57 @@ +name: ScreenUtilExamples +options: + bundleIdPrefix: com.dickydarmawan.screenutil + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true +packages: + ScreenUtil: + path: .. +targets: + "SwiftUI Demo": + type: application + platform: iOS + sources: + - SwiftUIDemo + - Shared + dependencies: + - package: ScreenUtil + scheme: + testTargets: + - ScreenUtilExamplesTests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.dickydarmawan.screenutil.swiftuidemo + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1,2" + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + SWIFT_VERSION: "6.0" + ScreenUtilExamplesTests: + type: bundle.unit-test + platform: iOS + sources: + - Tests + - Shared + dependencies: + - package: ScreenUtil + settings: + base: + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: "6.0" + "UIKit Demo": + type: application + platform: iOS + sources: + - UIKitDemo + - Shared + dependencies: + - package: ScreenUtil + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.dickydarmawan.screenutil.uikitdemo + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1,2" + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + SWIFT_VERSION: "6.0" diff --git a/Sources/ScreenUtil/Core/ScreenUtilConfiguration.swift b/Sources/ScreenUtil/Core/ScreenUtilConfiguration.swift index 5ea4fc6..08c759b 100644 --- a/Sources/ScreenUtil/Core/ScreenUtilConfiguration.swift +++ b/Sources/ScreenUtil/Core/ScreenUtilConfiguration.swift @@ -29,6 +29,11 @@ public struct ScreenUtilConfiguration: ScreenUtilConfigurable, Sendable, Equatab self.scalingLimits = scalingLimits } + /// Preset for designs targeting the iPhone 12 / 12 Pro / 13 / 14 (390×844 pt). + public static let iPhone12 = ScreenUtilConfiguration( + designSize: CGSize(width: 390, height: 844) + ) + /// Preset for designs targeting the iPhone 13 Pro (390×844 pt). public static let iPhone13Pro = ScreenUtilConfiguration( designSize: CGSize(width: 390, height: 844)