diff --git a/Examples/Shared/Data/ProfileRepository.swift b/Examples/Shared/Data/ProfileRepository.swift index 3d9a5cd..7a5b201 100644 --- a/Examples/Shared/Data/ProfileRepository.swift +++ b/Examples/Shared/Data/ProfileRepository.swift @@ -23,12 +23,15 @@ struct LiveProfileRepository: ProfileRepository { 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() } } diff --git a/Examples/Shared/Support/Format.swift b/Examples/Shared/Support/Format.swift deleted file mode 100644 index 9f82469..0000000 --- a/Examples/Shared/Support/Format.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// 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/Utils/DeviceMetricsRows.swift b/Examples/Shared/Utils/DeviceMetricsRows.swift new file mode 100644 index 0000000..fb89ae7 --- /dev/null +++ b/Examples/Shared/Utils/DeviceMetricsRows.swift @@ -0,0 +1,40 @@ +// +// DeviceMetricsRows.swift +// ScreenUtil Examples +// +// Single source of truth for the "Device & Scaling" rows, so the SwiftUI and UIKit +// cards render identical data. Snapshot fields come from the passed `metrics` (which +// SwiftUI observes, driving re-render); scale factors come from `screenUtil`. +// Created by Dicky Darmawan on 27/06/26. +// + +import CoreGraphics +import ScreenUtil + +/// One label/value line in the Device card. +struct MetricRow: Identifiable { + let label: String + let value: String + var id: String { label } +} + +enum DeviceMetricsRows { + /// The rows describing the current device + scaling state. + static func make(metrics: ScreenMetrics, scaling screenUtil: ScreenUtil) -> [MetricRow] { + [ + MetricRow(label: "device", value: "\(screenUtil.deviceType)"), + MetricRow(label: "screen", value: CGSize(width: metrics.width, height: metrics.height).dimensionsText), + MetricRow(label: "scaleW · scaleH", value: "\(screenUtil.scaleWidth.twoDecimalText) · \(screenUtil.scaleHeight.twoDecimalText)"), + MetricRow(label: "scaleText", value: screenUtil.scaleText.twoDecimalText), + MetricRow(label: "safeArea T/B", value: "\(metrics.safeAreaInsets.top.integerText) / \(metrics.safeAreaInsets.bottom.integerText)"), + MetricRow(label: "safeArea L/R", value: "\(metrics.safeAreaInsets.left.integerText) / \(metrics.safeAreaInsets.right.integerText)"), + MetricRow(label: "statusBar", value: metrics.statusBarHeight.integerText), + MetricRow(label: "screen W·H", value: "\(metrics.width.integerText) · \(metrics.height.integerText)"), + // All four ScaleType cases via the explicit entry points. + MetricRow(label: "scale(100,.width)", value: screenUtil.scale(for: 100, scaleType: .width).integerText), + MetricRow(label: "scale(100,.height)", value: screenUtil.scale(for: 100, scaleType: .height).integerText), + MetricRow(label: "scale(16,.radius)", value: screenUtil.scale(for: 16, scaleType: .radius).integerText), + MetricRow(label: "fastScale(100,.text)", value: screenUtil.fastScale(for: 100, scaleType: .text).integerText), + ] + } +} diff --git a/Examples/Shared/Utils/Error+UserMessage.swift b/Examples/Shared/Utils/Error+UserMessage.swift new file mode 100644 index 0000000..bd3cf1f --- /dev/null +++ b/Examples/Shared/Utils/Error+UserMessage.swift @@ -0,0 +1,18 @@ +// +// Error+UserMessage.swift +// ScreenUtil Examples +// +// Single source of truth for the user-facing load-failure message, so both demos +// show identical copy. +// Created by Dicky Darmawan on 27/06/26. +// + +import Foundation + +extension Error { + /// Human-readable message for the profile failure state. + var userMessage: String { + (self as? URLError)?.localizedDescription + ?? "Please check your connection and try again." + } +} diff --git a/Examples/Shared/Support/ExampleProfile.swift b/Examples/Shared/Utils/ExampleProfile.swift similarity index 83% rename from Examples/Shared/Support/ExampleProfile.swift rename to Examples/Shared/Utils/ExampleProfile.swift index 9a44e58..134b9dc 100644 --- a/Examples/Shared/Support/ExampleProfile.swift +++ b/Examples/Shared/Utils/ExampleProfile.swift @@ -16,4 +16,7 @@ enum ExampleProfile { /// Tag chips shown on the profile. static let tags = ["swift", "ios", "uikit", "swiftui"] + + /// Captions for the horizontal highlights carousel. + static let highlights = ["Swift", "iOS", "UIKit", "SwiftUI", "Perf", "Open"] } diff --git a/Examples/Shared/Utils/Format.swift b/Examples/Shared/Utils/Format.swift new file mode 100644 index 0000000..467e272 --- /dev/null +++ b/Examples/Shared/Utils/Format.swift @@ -0,0 +1,30 @@ +// +// Format.swift +// ScreenUtil Examples +// +// Value-to-display-string formatting, as extensions on the value types so they read +// with dot syntax (`width.integerText`, `repos.compactText`). Shared by both demos. +// Created by Dicky Darmawan on 21/06/26. +// + +import CoreGraphics +import Foundation + +extension CGFloat { + /// e.g. `123` + var integerText: String { String(format: "%.0f", self) } + /// e.g. `1.25` + var twoDecimalText: String { String(format: "%.2f", self) } +} + +extension CGSize { + /// e.g. `120×80` + var dimensionsText: String { String(format: "%.0f×%.0f", width, height) } +} + +extension Int { + /// Compact count, e.g. `999` / `3.4k`. + var compactText: String { + self >= 1000 ? String(format: "%.1fk", Double(self) / 1000) : "\(self)" + } +} diff --git a/Examples/Shared/Utils/Palette.swift b/Examples/Shared/Utils/Palette.swift new file mode 100644 index 0000000..42a3be9 --- /dev/null +++ b/Examples/Shared/Utils/Palette.swift @@ -0,0 +1,47 @@ +// +// Palette.swift +// ScreenUtil Examples +// +// Centralised colour palette shared by both demo apps, exposed as extensions so it +// reads with leading-dot syntax (`.card`, `.secondaryText`) like the system colours. +// UIColor is the single source of truth; the SwiftUI `Color` values derive from it. +// Created by Dicky Darmawan on 27/06/26. +// + +import SwiftUI +import UIKit + +// MARK: - UIColor source of truth (used by the UIKit demo) + +extension UIColor { + /// Banner brand gradient (top-leading → bottom-trailing). + static let bannerColors: [UIColor] = [.systemBlue, .systemPurple] + /// Highlight-circle gradient (top-light → accent). + static let accentColors: [UIColor] = [UIColor.systemBlue.withAlphaComponent(0.85), .systemBlue] + + static let card = UIColor.secondarySystemBackground + static let chip = UIColor.systemBlue.withAlphaComponent(0.15) + static let avatarFill = UIColor.secondarySystemBackground + static let avatarStroke = UIColor.systemBackground + static let secondaryText = UIColor.secondaryLabel + static let onAccent = UIColor.white +} + +// MARK: - SwiftUI mirrors (derived — one definition per colour) + +/// Single colours on `ShapeStyle where Self == Color`, so they work with leading-dot +/// in style contexts: `.background(.card)`, `.foregroundStyle(.secondaryText)`. +extension ShapeStyle where Self == Color { + static var card: Color { Color(uiColor: .card) } + static var chip: Color { Color(uiColor: .chip) } + static var avatarFill: Color { Color(uiColor: .avatarFill) } + static var avatarStroke: Color { Color(uiColor: .avatarStroke) } + static var secondaryText: Color { Color(uiColor: .secondaryText) } + static var onAccent: Color { Color(uiColor: .onAccent) } +} + +/// Gradient colour arrays (no leading-dot for `[Color]`, so referenced as `Color.bannerColors`). +extension Color { + static var bannerColors: [Color] { UIColor.bannerColors.map(Color.init(uiColor:)) } + static var accentColors: [Color] { UIColor.accentColors.map(Color.init(uiColor:)) } +} diff --git a/Examples/SwiftUIDemo/ProfileView.swift b/Examples/SwiftUIDemo/ProfileView.swift index c291c9e..9c4f2ae 100644 --- a/Examples/SwiftUIDemo/ProfileView.swift +++ b/Examples/SwiftUIDemo/ProfileView.swift @@ -20,26 +20,30 @@ struct ProfileView: View { } 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() } } + NavigationStack { + 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(error.userMessage) + } actions: { + Button("Retry") { Task { await model.retry() } } + } + case .loaded(let profile): + loadedContent(profile) } - case .loaded(let profile): - loadedContent(profile) } + .navigationTitle("SwiftUI Demo") + .navigationBarTitleDisplayMode(.inline) + .task { await model.load() } } - .task { await model.load() } } private func loadedContent(_ profile: Profile) -> some View { @@ -56,11 +60,6 @@ struct ProfileView: View { .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") { diff --git a/Examples/SwiftUIDemo/Sections/DeviceCard.swift b/Examples/SwiftUIDemo/Sections/DeviceCard.swift index b216875..5db3993 100644 --- a/Examples/SwiftUIDemo/Sections/DeviceCard.swift +++ b/Examples/SwiftUIDemo/Sections/DeviceCard.swift @@ -22,66 +22,21 @@ struct DeviceCard: View { 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()) + ForEach(DeviceMetricsRows.make(metrics: metrics, scaling: screenUtil)) { item in + row(item.label, item.value) + } } .padding(16.w) .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary, in: .rect(cornerRadius: 16.r)) + .background(.card, in: .rect(cornerRadius: 16.r)) } private func row(_ key: String, _ value: String) -> some View { HStack { - Text(key).foregroundStyle(.secondary) + Text(key).foregroundStyle(.secondaryText) 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 index fe614b6..7c44d96 100644 --- a/Examples/SwiftUIDemo/Sections/Highlights.swift +++ b/Examples/SwiftUIDemo/Sections/Highlights.swift @@ -10,20 +10,21 @@ import ScreenUtil import SwiftUI struct Highlights: View { - private let titles = ["Swift", "iOS", "UIKit", "SwiftUI", "Perf", "Open"] + private let titles = ExampleProfile.highlights 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) + .fill(LinearGradient(colors: Color.accentColors, startPoint: .top, endPoint: .bottom)) .frame(width: diameter, height: diameter) - .overlay(Text(title.prefix(1)).font(.system(size: fast.text(24), weight: .bold)).foregroundStyle(.white)) + .overlay(Text(title.prefix(1)).font(.system(size: fast.text(24), weight: .bold)).foregroundStyle(.onAccent)) Text(title).font(.system(size: fast.text(12))) } } diff --git a/Examples/SwiftUIDemo/Sections/ProfileHeader.swift b/Examples/SwiftUIDemo/Sections/ProfileHeader.swift index 6054d05..2aacc09 100644 --- a/Examples/SwiftUIDemo/Sections/ProfileHeader.swift +++ b/Examples/SwiftUIDemo/Sections/ProfileHeader.swift @@ -18,18 +18,18 @@ struct ProfileHeader: View { var body: some View { VStack(alignment: .leading, spacing: 12.h) { ZStack(alignment: .bottomLeading) { - LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) + LinearGradient(colors: Color.bannerColors, 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) + Color.avatarFill } .frame(width: avatarSize.width, height: avatarSize.height) .clipShape(.circle) - .overlay(Circle().strokeBorder(.background, lineWidth: 4.w)) + .overlay(Circle().strokeBorder(.avatarStroke, lineWidth: 4.w)) .offset(x: 20.w, y: avatarSize.height / 2) } .padding(.bottom, avatarSize.height / 2) @@ -39,7 +39,7 @@ struct ProfileHeader: View { .font(.system(size: 24.sp, weight: .bold)) Text("@\(profile.username)") .font(.system(size: 15.sp)) - .foregroundStyle(.secondary) + .foregroundStyle(.secondaryText) if let bio = profile.bio { Text(bio) .font(.system(size: 15.sp)) diff --git a/Examples/SwiftUIDemo/Sections/StatsRow.swift b/Examples/SwiftUIDemo/Sections/StatsRow.swift index b2eff8d..d00a73e 100644 --- a/Examples/SwiftUIDemo/Sections/StatsRow.swift +++ b/Examples/SwiftUIDemo/Sections/StatsRow.swift @@ -23,23 +23,20 @@ struct StatsRow: View { 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)) + Text(stat.value.compactText) .font(.system(size: sizes[0], weight: .bold)) Text(stat.label) .font(.system(size: sizes[1])) - .foregroundStyle(.secondary) + .foregroundStyle(.secondaryText) } .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)" + .background(.card, in: .rect(cornerRadius: 16.r)) } } diff --git a/Examples/SwiftUIDemo/Sections/TagChips.swift b/Examples/SwiftUIDemo/Sections/TagChips.swift index e878265..91a9939 100644 --- a/Examples/SwiftUIDemo/Sections/TagChips.swift +++ b/Examples/SwiftUIDemo/Sections/TagChips.swift @@ -16,6 +16,7 @@ struct TagChips: 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) @@ -23,7 +24,7 @@ struct TagChips: View { .padding(.horizontal, 12.w) .padding(.vertical, 6.h) .frame(minWidth: widths[index]) - .background(.tint.opacity(0.15), in: .rect(cornerRadius: radii[index])) + .background(.chip, in: .rect(cornerRadius: radii[index])) } } } diff --git a/Examples/Tests/ScalingShowcaseTests.swift b/Examples/Tests/ScalingShowcaseTests.swift new file mode 100644 index 0000000..56c6a37 --- /dev/null +++ b/Examples/Tests/ScalingShowcaseTests.swift @@ -0,0 +1,45 @@ +// +// ScalingShowcaseTests.swift +// ScreenUtil Examples +// +// Exercises the bulk + per-call geometry scaling APIs that the demo claims to +// showcase. Previously these lived as a contrived `selfTest()` row inside the +// Device cards; proving coverage in a test keeps the views focused on rendering. +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit +import XCTest + +final class ScalingShowcaseTests: XCTestCase { + func testBatchScalerCoversEveryBulkEntryPoint() { + let batch = ScreenUtil.shared.batchScaler + XCTAssertEqual(batch.widths([10, 20, 30]).count, 3) + XCTAssertEqual(batch.heights([10, 20]).count, 2) + XCTAssertEqual(batch.fontSizes([12, 14]).count, 2) + XCTAssertEqual(batch.radii([8, 12]).count, 2) + XCTAssertEqual(batch.sizes([CGSize(width: 10, height: 10)]).count, 1) + XCTAssertEqual(batch.points([CGPoint(x: 10, y: 10), CGPoint(x: 20, y: 20)]).count, 2) + XCTAssertEqual(batch.rects([CGRect(x: 0, y: 0, width: 50, height: 50)]).count, 1) + XCTAssertEqual(batch.scale([100, 200], scaleType: .height).count, 2) + XCTAssertEqual(batch.edgeInsets([UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)]).count, 1) + } + + func testFastScaleAndGeometryHelpersAreCallable() { + 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)) + XCTAssertGreaterThan(withFastScale { $0.width(20) }, 0) + + _ = 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() + + XCTAssertNotNil(UIFont.customFont(name: "Menlo", size: 12, scaled: true)) + } +} diff --git a/Examples/UIKitDemo/ProfileViewController.swift b/Examples/UIKitDemo/ProfileViewController.swift index f9e6146..40f5643 100644 --- a/Examples/UIKitDemo/ProfileViewController.swift +++ b/Examples/UIKitDemo/ProfileViewController.swift @@ -20,21 +20,15 @@ final class ProfileViewController: UIViewController { 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 lazy var errorView = ErrorStateView { [weak self] in self?.startLoad() } private let scrollView: UIScrollView = { let scrollView = UIScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.alwaysBounceVertical = true return scrollView }() @@ -43,19 +37,22 @@ final class ProfileViewController: UIViewController { let stack = UIStackView() stack.axis = .vertical stack.alignment = .fill - stack.translatesAutoresizingMaskIntoConstraints = false return stack }() override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .systemBackground + title = "UIKit Demo" + navigationItem.largeTitleDisplayMode = .never contentStack.isLayoutMarginsRelativeArrangement = true - view.addSubview(scrollView) - scrollView.addSubview(contentStack) - view.addSubview(spinner) - view.addSubview(errorView) + view.addAutoLayout(scrollView) + scrollView.addAutoLayout(contentStack) + view.addAutoLayout(spinner) + view.addAutoLayout(errorView) + NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: view.topAnchor), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -114,7 +111,7 @@ final class ProfileViewController: UIViewController { spinner.isHidden = true errorView.isHidden = false scrollView.isHidden = true - errorView.configure(message: Self.message(for: error)) + errorView.configure(message: error.userMessage) case .loaded(let profile): spinner.stopAnimating() spinner.isHidden = true @@ -132,9 +129,4 @@ final class ProfileViewController: UIViewController { 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 index fa4c231..b55b47a 100644 --- a/Examples/UIKitDemo/SceneDelegate.swift +++ b/Examples/UIKitDemo/SceneDelegate.swift @@ -19,10 +19,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 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() + // Navigation controller gives the inline "UIKit Demo" title, mirroring the + // SwiftUI demo's NavigationStack("SwiftUI Demo"). + window.rootViewController = UINavigationController(rootViewController: ProfileViewController()) self.window = window window.makeKeyAndVisible() } diff --git a/Examples/UIKitDemo/UIKitHelpers.swift b/Examples/UIKitDemo/UIKitHelpers.swift new file mode 100644 index 0000000..fb8a4d0 --- /dev/null +++ b/Examples/UIKitDemo/UIKitHelpers.swift @@ -0,0 +1,45 @@ +// +// UIKitHelpers.swift +// ScreenUtil Examples +// +// Small UIKit conveniences shared across the demo's views, so the view code reads +// as layout intent rather than boilerplate. UIKit-only (kept out of Shared/). +// Created by Dicky Darmawan on 27/06/26. +// + +import ScreenUtil +import UIKit + +extension UILabel { + /// A label with a ScreenUtil-scaled system font and common styling, created in one call. + static func scaled( + size: CGFloat, + weight: UIFont.Weight = .regular, + color: UIColor = .label, + align: NSTextAlignment = .natural, + lines: Int = 1 + ) -> UILabel { + let label = UILabel() + label.font = .systemFont(ofSize: size, weight: weight, scaled: true) + label.textColor = color + label.textAlignment = align + label.numberOfLines = lines + return label + } +} + +extension UIFont { + /// A system font whose size is **already** scaled by the caller (e.g. via `fast.text` + /// or `batchScaler.fontSizes`), so ScreenUtil must not scale it again. + static func preScaled(_ size: CGFloat, weight: Weight = .regular) -> UIFont { + .systemFont(ofSize: size, weight: weight).scaled(false) + } +} + +extension UIView { + /// Adds a subview already opted into Auto Layout (no `translatesAutoresizing…` boilerplate). + func addAutoLayout(_ subview: UIView) { + subview.translatesAutoresizingMaskIntoConstraints = false + addSubview(subview) + } +} diff --git a/Examples/UIKitDemo/Views/DeviceCardView.swift b/Examples/UIKitDemo/Views/DeviceCardView.swift index b36813f..c413f7a 100644 --- a/Examples/UIKitDemo/Views/DeviceCardView.swift +++ b/Examples/UIKitDemo/Views/DeviceCardView.swift @@ -12,39 +12,22 @@ import UIKit final class DeviceCardView: UIView { init() { super.init(frame: .zero) - backgroundColor = .secondarySystemBackground + + backgroundColor = .card 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 rows = DeviceMetricsRows.make(metrics: screenUtil.getScreenMetrics(), scaling: screenUtil) - let title = UILabel() + let title = UILabel.scaled(size: 17, weight: .semibold) 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) + addAutoLayout(stack) + NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), stack.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), @@ -55,38 +38,11 @@ final class DeviceCardView: UIView { 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) + private func makeRow(_ metric: MetricRow) -> UIStackView { + let key = UILabel.scaled(size: 13, color: .secondaryText) + key.text = metric.label + let value = UILabel.scaled(size: 13, weight: .medium, align: .right) + value.text = metric.value 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 index 5f5dc09..c80f4eb 100644 --- a/Examples/UIKitDemo/Views/ErrorStateView.swift +++ b/Examples/UIKitDemo/Views/ErrorStateView.swift @@ -12,14 +12,7 @@ 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 let messageLabel = UILabel.scaled(size: 15, color: .secondaryText, align: .center, lines: 0) private lazy var retryButton: UIButton = { var config = UIButton.Configuration.borderedProminent() @@ -33,12 +26,13 @@ final class ErrorStateView: UIView { 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) + addAutoLayout(stack) + NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: centerXAnchor), stack.centerYAnchor.constraint(equalTo: centerYAnchor), diff --git a/Examples/UIKitDemo/Views/GradientView.swift b/Examples/UIKitDemo/Views/GradientView.swift index 34de9c4..2c9df00 100644 --- a/Examples/UIKitDemo/Views/GradientView.swift +++ b/Examples/UIKitDemo/Views/GradientView.swift @@ -22,6 +22,7 @@ final class GradientView: UIView { 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 diff --git a/Examples/UIKitDemo/Views/HighlightsStripView.swift b/Examples/UIKitDemo/Views/HighlightsStripView.swift index c795581..5fd916a 100644 --- a/Examples/UIKitDemo/Views/HighlightsStripView.swift +++ b/Examples/UIKitDemo/Views/HighlightsStripView.swift @@ -12,14 +12,14 @@ import ScreenUtil import UIKit final class HighlightsStripView: UIView { - private let titles = ["Swift", "iOS", "UIKit", "SwiftUI", "Perf", "Open"] + private let titles = ExampleProfile.highlights 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 @@ -35,10 +35,10 @@ final class HighlightsStripView: UIView { row.axis = .horizontal row.alignment = .top row.spacing = fast.width(14) - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(scroll) - scroll.addSubview(row) + addAutoLayout(scroll) + scroll.addAutoLayout(row) + NSLayoutConstraint.activate([ scroll.topAnchor.constraint(equalTo: topAnchor), scroll.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -bleed), @@ -58,7 +58,7 @@ final class HighlightsStripView: UIView { // Gradient circle (top-light → accent) approximating SwiftUI's subtle // Color.accentColor.gradient. let circle = GradientView( - colors: [UIColor.systemBlue.withAlphaComponent(0.85), .systemBlue], + colors: UIColor.accentColors, start: CGPoint(x: 0.5, y: 0), end: CGPoint(x: 0.5, y: 1) ) @@ -66,16 +66,16 @@ final class HighlightsStripView: UIView { circle.layer.cornerRadius = diameter / 2 circle.clipsToBounds = true + // Fonts use pre-scaled sizes (fast.text already scales), so skip auto-scaling. 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) + letter.textColor = .onAccent + letter.font = .preScaled(fast.text(24), weight: .bold) + circle.addAutoLayout(letter) let caption = UILabel() caption.text = title - caption.font = .systemFont(ofSize: fast.text(12)).scaled(false) + caption.font = .preScaled(fast.text(12)) caption.textAlignment = .center let item = UIStackView(arrangedSubviews: [circle, caption]) diff --git a/Examples/UIKitDemo/Views/ProfileHeaderView.swift b/Examples/UIKitDemo/Views/ProfileHeaderView.swift index c0413de..b29928d 100644 --- a/Examples/UIKitDemo/Views/ProfileHeaderView.swift +++ b/Examples/UIKitDemo/Views/ProfileHeaderView.swift @@ -12,7 +12,7 @@ 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]) + let banner = GradientView(colors: UIColor.bannerColors) banner.cornerRadius(20) // .r scaled banner.translatesAutoresizingMaskIntoConstraints = false return banner @@ -20,36 +20,39 @@ final class ProfileHeaderView: UIView { private let avatar: UIImageView = { let avatar = UIImageView() - avatar.backgroundColor = .secondarySystemBackground + avatar.backgroundColor = .avatarFill avatar.contentMode = .scaleAspectFill avatar.clipsToBounds = true avatar.borderWidth(4) // .w scaled - avatar.layer.borderColor = UIColor.systemBackground.cgColor + avatar.layer.borderColor = UIColor.avatarStroke.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) + private let nameLabel = UILabel.scaled(size: 24, weight: .bold) + private let handleLabel = UILabel.scaled(size: 15, color: .secondaryText) + private let bioLabel = UILabel.scaled(size: 15, 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) + addAutoLayout(identity) + avatar.size(width: 96, height: 96) // already-active scaled constraints + NSLayoutConstraint.activate([ banner.topAnchor.constraint(equalTo: topAnchor), banner.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -74,12 +77,4 @@ final class ProfileHeaderView: UIView { 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 index d01cb62..08572e5 100644 --- a/Examples/UIKitDemo/Views/StatsRowView.swift +++ b/Examples/UIKitDemo/Views/StatsRowView.swift @@ -12,20 +12,21 @@ import UIKit final class StatsRowView: UIView { init(profile: Profile) { super.init(frame: .zero) - backgroundColor = .secondarySystemBackground + backgroundColor = .card 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), + column(value: profile.repos.compactText, label: "Repos", sizes: sizes), + column(value: profile.followers.compactText, label: "Followers", sizes: sizes), + column(value: profile.following.compactText, label: "Following", sizes: sizes), ] + let row = UIStackView(arrangedSubviews: columns) row.distribution = .fillEqually - row.translatesAutoresizingMaskIntoConstraints = false - addSubview(row) + addAutoLayout(row) + NSLayoutConstraint.activate([ row.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), row.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), @@ -37,20 +38,19 @@ final class StatsRowView: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func column(value: String, label: String, sizes: [CGFloat]) -> UIStackView { + // sizes are already scaled by batchScaler.fontSizes, so use preScaled fonts. let valueLabel = UILabel(); valueLabel.text = value - valueLabel.font = .systemFont(ofSize: sizes[0], weight: .bold).scaled(false) // already scaled + valueLabel.font = .preScaled(sizes[0], weight: .bold) valueLabel.textAlignment = .center + let nameLabel = UILabel(); nameLabel.text = label - nameLabel.font = .systemFont(ofSize: sizes[1], weight: .regular).scaled(false) - nameLabel.textColor = .secondaryLabel + nameLabel.font = .preScaled(sizes[1]) + nameLabel.textColor = .secondaryText 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 index 13b2ba2..8ebe638 100644 --- a/Examples/UIKitDemo/Views/TagChipsView.swift +++ b/Examples/UIKitDemo/Views/TagChipsView.swift @@ -12,13 +12,15 @@ 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) + addAutoLayout(row) + NSLayoutConstraint.activate([ row.topAnchor.constraint(equalTo: topAnchor), row.bottomAnchor.constraint(equalTo: bottomAnchor), @@ -34,7 +36,7 @@ final class TagChipsView: UIView { 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.backgroundColor = .chip label.layer.cornerRadius = radius label.clipsToBounds = true label.textInsets = .scaled(horizontal: 12, vertical: 6)