diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2a665f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: Something behaves incorrectly +title: "" +labels: bug +assignees: "" +--- + +**What happened** + +**What you expected** + +**Pose / size-class configuration** + + +**Environment** +- DuoLayoutKit version: +- Xcode version: +- OS / device: + +**Minimal reproduction** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..fa1e208 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an addition or change +title: "" +labels: enhancement +assignees: "" +--- + +**The problem** + + +**Proposed API** + + +**Alternatives considered** + +**Notes** + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4ee6984 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +## Summary + + + +## Checklist + +- [ ] `swift test` passes +- [ ] Public symbols have `///` doc comments +- [ ] `CHANGELOG.md` updated under `[Unreleased]` +- [ ] No branching on device model, `UIScreen.main`, idiom, or interface orientation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7da7eb4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build & test (macOS host) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show Swift version + run: swift --version + - name: Build + run: swift build -v + - name: Test + run: swift test -v + + ios-build: + name: Build for iOS & docs + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Build for generic iOS device + run: > + xcodebuild build + -scheme DuoLayoutKit + -destination 'generic/platform=iOS' + -skipPackagePluginValidation + - name: Build documentation + run: > + xcodebuild docbuild + -scheme DuoLayoutKit + -destination 'generic/platform=iOS' + -skipPackagePluginValidation diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b11c8e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to DuoLayoutKit are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `DuoLayoutMode` — a semantic `.compact` / `.expanded` value derived from the + horizontal size class, with an `init(horizontalSizeClass:)` and an `isExpanded` + helper. +- `DuoLayout` — a container view that rebuilds its content for the current + `DuoLayoutMode` and publishes that mode into the environment. +- `\.duoLayoutMode` environment value and the `.publishDuoLayoutMode()` view + modifier for reading the mode from any descendant view. +- DocC catalog, MIT license, and GitHub Actions CI (macOS host tests + iOS build). + +### Notes + +- Verification of the folded / unfolded poses in the iPhone Duo simulator is + pending the public Xcode 27.1 beta. + +[Unreleased]: https://github.com/yunodevs/DuoLayoutKit/commits/main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8bd2df9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to DuoLayoutKit + +Thanks for your interest in improving DuoLayoutKit. This is a small, focused +library, so the bar for contributions is mostly about keeping it that way. + +## Ground rules + +- **Build on size classes, not device or orientation checks.** DuoLayoutKit + follows Apple's iPhone Duo guidance: adapt to the *available space* through + size classes, safe areas, and view/scene geometry. Pull requests that branch + on `UIDevice`, `UIScreen.main`, idiom, or interface orientation will not be + merged. +- **Public API stays documented.** Every `public` symbol needs a `///` doc + comment. +- **Keep the surface small.** New API should earn its place. Open an issue to + discuss anything beyond a bug fix before writing code. + +## Development + +```sh +swift build +swift test +``` + +`swift test` runs the pure layout logic on the macOS host. Building for iOS: + +```sh +xcodebuild build -scheme DuoLayoutKit -destination 'generic/platform=iOS' +``` + +CI runs both on every pull request. + +## Submitting changes + +1. Fork and create a topic branch (`feat/…` or `fix/…`). +2. Add or update tests for behaviour changes. +3. Update `CHANGELOG.md` under `[Unreleased]`. +4. Open a pull request against `main` with a clear description. + +## Reporting bugs + +Use the issue templates. For layout bugs, please say which pose / size-class +configuration you saw the problem in. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4be44ba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ryosuke Yunoki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift index 07e4c2d..5d51992 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,23 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "DuoLayoutKit", + platforms: [ + .iOS(.v17), + .macCatalyst(.v17), + // Supported so `swift test` can exercise the pure layout logic on a Mac host. + .macOS(.v14), + ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "DuoLayoutKit", targets: ["DuoLayoutKit"] ), ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( name: "DuoLayoutKit" ), diff --git a/README.md b/README.md index 180eda7..a6e6801 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,32 @@ # DuoLayoutKit -Adaptive layout utilities for iPhone Duo — SwiftUI helpers for foldable -states, inner/outer displays, and split layouts. +[![CI](https://github.com/yunodevs/DuoLayoutKit/actions/workflows/ci.yml/badge.svg)](https://github.com/yunodevs/DuoLayoutKit/actions/workflows/ci.yml) +[![Swift 6](https://img.shields.io/badge/Swift-6-orange.svg)](https://swift.org) +[![Platforms](https://img.shields.io/badge/platforms-iOS%2017%2B%20%7C%20Mac%20Catalyst%2017%2B-blue.svg)](#requirements) +[![SwiftPM](https://img.shields.io/badge/SwiftPM-compatible-brightgreen.svg)](https://swift.org/package-manager) +[![License: MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg)](LICENSE) + +Adaptive layout utilities for iPhone Duo — SwiftUI helpers that turn the +folded and unfolded poses into plain layout intent. ## Why -iPhone Duo introduces folded and unfolded poses, an inner and an outer -display, and new multitasking layouts. DuoLayoutKit gives you small, -composable SwiftUI helpers so your app adapts to every pose without -scattering size-class checks across your codebase. +iPhone Duo introduces folded and unfolded poses and new multitasking +layouts. Apple's guidance is to adapt with **size classes**, not device or +orientation checks. DuoLayoutKit wraps that guidance in small, composable +SwiftUI helpers so your app expresses layout *intent* — "compact" vs. +"expanded" — instead of scattering `horizontalSizeClass` comparisons across +your codebase. Because it is built on size classes, the same code also does +the right thing on non-foldable iPhones and in iPad Split View. ## Installation ### Swift Package Manager -In Xcode: **File > Add Package Dependencies…** and enter: -https://github.com/yunodevs/DuoLayoutKit - +In Xcode: **File > Add Package Dependencies…** and enter +`https://github.com/yunodevs/DuoLayoutKit`. Or add it to your `Package.swift`: @@ -30,23 +38,103 @@ dependencies: [ ## Usage +### `DuoLayout` — branch on the current layout mode + +```swift +import SwiftUI +import DuoLayoutKit + +struct LibraryScreen: View { + var body: some View { + DuoLayout { mode in + switch mode { + case .compact: + NavigationStack { + BookList() + } + case .expanded: + NavigationSplitView { + BookList() + } detail: { + BookDetail() + } + } + } + } +} +``` + +The closure re-runs whenever the mode changes — unfolding iPhone Duo, +rotating, or entering Split View. + +### `\.duoLayoutMode` — read the mode anywhere + +Publish the mode once near the root of your scene, then read it from any +descendant without threading it through initializers: + ```swift -// API under design — coming with v0.1.0 +@main +struct BooksApp: App { + var body: some Scene { + WindowGroup { + RootView() + .publishDuoLayoutMode() + } + } +} + +struct Toolbar: View { + @Environment(\.duoLayoutMode) private var mode + + var body: some View { + HStack { + if mode.isExpanded { + SearchField() + } + AddButton() + } + } +} +``` + +### `DuoLayoutMode` — the model + +`DuoLayoutMode` is derived purely from the horizontal size class: + +| Context | Mode | +| --- | --- | +| iPhone Duo unfolded · iPad full screen · wide split pane | `.expanded` | +| iPhone Duo folded · iPhone portrait · narrow split pane | `.compact` | + +```swift +DuoLayoutMode(horizontalSizeClass: .regular) // .expanded +DuoLayoutMode(horizontalSizeClass: .compact) // .compact ``` ## Requirements -- iOS 27.1+ -- Xcode 27.1+ +- iOS 17+ / Mac Catalyst 17+ - Swift 6+ +Testing the folded and unfolded poses in Simulator additionally requires +Xcode 27.1 and the iPhone Duo simulator (DeviceHub). + +## Documentation + +Every public symbol ships with doc comments, and the package includes a +[DocC catalog](Sources/DuoLayoutKit/DuoLayoutKit.docc). Build it in Xcode with +**Product > Build Documentation**. + ## Roadmap -- [ ] Fold-state-aware layout container +- [x] Fold-state-aware layout container (`DuoLayout`, `DuoLayoutMode`) - [ ] Inner/outer display helpers - [ ] Split layout utilities - [ ] UIKit support +See [CHANGELOG.md](CHANGELOG.md) for release notes and +[CONTRIBUTING.md](CONTRIBUTING.md) to get involved. + ## License -MIT +DuoLayoutKit is available under the MIT license. See [LICENSE](LICENSE). diff --git a/Sources/DuoLayoutKit/DuoLayout.swift b/Sources/DuoLayoutKit/DuoLayout.swift new file mode 100644 index 0000000..28eb6bd --- /dev/null +++ b/Sources/DuoLayoutKit/DuoLayout.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// A container that builds different content for the compact and expanded +/// ``DuoLayoutMode``, switching automatically as the scene resizes — for example when +/// iPhone Duo is unfolded, or when a window enters Split View. +/// +/// ```swift +/// DuoLayout { mode in +/// switch mode { +/// case .compact: +/// NavigationStack { LibraryList() } +/// case .expanded: +/// NavigationSplitView { +/// LibrarySidebar() +/// } detail: { +/// LibraryDetail() +/// } +/// } +/// } +/// ``` +/// +/// The closure receives the current mode, and the same value is published into the +/// environment (`\.duoLayoutMode`) for descendant views, so nested content does not +/// need to thread the mode through manually. +public struct DuoLayout: View { + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + private let content: (DuoLayoutMode) -> Content + + /// Creates a layout container. + /// + /// - Parameter content: A view builder invoked with the current ``DuoLayoutMode`` + /// whenever it changes. + public init(@ViewBuilder content: @escaping (DuoLayoutMode) -> Content) { + self.content = content + } + + public var body: some View { + let mode = DuoLayoutMode(horizontalSizeClass: horizontalSizeClass) + content(mode) + .environment(\.duoLayoutMode, mode) + } +} diff --git a/Sources/DuoLayoutKit/DuoLayoutKit.docc/DuoLayoutKit.md b/Sources/DuoLayoutKit/DuoLayoutKit.docc/DuoLayoutKit.md new file mode 100644 index 0000000..903ce96 --- /dev/null +++ b/Sources/DuoLayoutKit/DuoLayoutKit.docc/DuoLayoutKit.md @@ -0,0 +1,51 @@ +# ``DuoLayoutKit`` + +Adaptive layout utilities for iPhone Duo — SwiftUI helpers that turn the folded +and unfolded poses into plain layout intent. + +## Overview + +iPhone Duo introduces folded and unfolded poses and new multitasking layouts. +Apple's guidance is to adapt with **size classes**, not device or orientation +checks. DuoLayoutKit wraps that guidance in a small set of SwiftUI helpers so +your code expresses *intent* — compact versus expanded — instead of scattering +`horizontalSizeClass` comparisons across the codebase. + +Because everything is derived from the horizontal size class, the same code also +behaves correctly on non-foldable iPhones and in iPad Split View. + +```swift +DuoLayout { mode in + switch mode { + case .compact: + NavigationStack { BookList() } + case .expanded: + NavigationSplitView { + BookList() + } detail: { + BookDetail() + } + } +} +``` + +To read the mode deeper in the hierarchy, publish it once near the root of your +scene and pick it up from the environment: + +```swift +RootView() + .publishDuoLayoutMode() + +// elsewhere +@Environment(\.duoLayoutMode) private var mode +``` + +## Topics + +### Layout mode + +- ``DuoLayoutMode`` + +### Containers + +- ``DuoLayout`` diff --git a/Sources/DuoLayoutKit/DuoLayoutKit.swift b/Sources/DuoLayoutKit/DuoLayoutKit.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/DuoLayoutKit/DuoLayoutKit.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/DuoLayoutKit/DuoLayoutMode.swift b/Sources/DuoLayoutKit/DuoLayoutMode.swift new file mode 100644 index 0000000..825c748 --- /dev/null +++ b/Sources/DuoLayoutKit/DuoLayoutMode.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// A semantic description of how much horizontal space the current scene offers. +/// +/// `DuoLayoutMode` is derived entirely from the active horizontal size class, which is +/// the signal Apple recommends for adapting layout on iPhone Duo. It deliberately does +/// **not** try to detect the device model, the physical fold angle, or the interface +/// orientation — none of those are reliable inputs for layout decisions. +/// +/// | Context | Horizontal size class | Mode | +/// | --- | --- | --- | +/// | iPhone Duo, unfolded (inner display) | `.regular` | ``expanded`` | +/// | iPhone Duo, folded (outer display) | `.compact` | ``compact`` | +/// | iPhone in portrait | `.compact` | ``compact`` | +/// | iPad full screen, or a wide split-view pane | `.regular` | ``expanded`` | +/// +/// Because the mapping is expressed in terms of size classes, code written against +/// `DuoLayoutMode` also behaves correctly on non-foldable devices and in +/// multitasking configurations. +public enum DuoLayoutMode: Sendable, Hashable, CaseIterable { + + /// A single-column experience suited to a narrow scene, such as the outer display + /// of a folded iPhone Duo or a portrait iPhone. + case compact + + /// A multi-column experience suited to a wide scene, such as the inner display of + /// an unfolded iPhone Duo or a full-screen iPad. + case expanded + + /// Derives the layout mode from a horizontal size class. + /// + /// - Parameter horizontalSizeClass: The value from + /// `@Environment(\.horizontalSizeClass)` (SwiftUI) or + /// `traitCollection.horizontalSizeClass` (UIKit). A `nil` value — which SwiftUI + /// reports before a view is placed in a window — is treated as ``compact``. + public init(horizontalSizeClass: UserInterfaceSizeClass?) { + self = horizontalSizeClass == .regular ? .expanded : .compact + } + + /// Whether this mode represents a wide, multi-column scene. + public var isExpanded: Bool { self == .expanded } +} diff --git a/Sources/DuoLayoutKit/EnvironmentValues+DuoLayoutMode.swift b/Sources/DuoLayoutKit/EnvironmentValues+DuoLayoutMode.swift new file mode 100644 index 0000000..09c59a3 --- /dev/null +++ b/Sources/DuoLayoutKit/EnvironmentValues+DuoLayoutMode.swift @@ -0,0 +1,48 @@ +import SwiftUI + +private struct DuoLayoutModeKey: EnvironmentKey { + static let defaultValue = DuoLayoutMode.compact +} + +extension EnvironmentValues { + + /// The current ``DuoLayoutMode`` for the surrounding view hierarchy. + /// + /// The value defaults to ``DuoLayoutMode/compact`` until an ancestor publishes the + /// real mode with `publishDuoLayoutMode()`. Read it from any descendant view: + /// + /// ```swift + /// @Environment(\.duoLayoutMode) private var layoutMode + /// ``` + public var duoLayoutMode: DuoLayoutMode { + get { self[DuoLayoutModeKey.self] } + set { self[DuoLayoutModeKey.self] = newValue } + } +} + +private struct PublishDuoLayoutMode: ViewModifier { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + func body(content: Content) -> some View { + content.environment(\.duoLayoutMode, DuoLayoutMode(horizontalSizeClass: horizontalSizeClass)) + } +} + +extension View { + + /// Computes the current ``DuoLayoutMode`` from the active horizontal size class and + /// publishes it into the environment for descendant views to read via + /// `\.duoLayoutMode`. + /// + /// Apply it once, high in the hierarchy — typically on the root of a scene: + /// + /// ```swift + /// WindowGroup { + /// RootView() + /// .publishDuoLayoutMode() + /// } + /// ``` + public func publishDuoLayoutMode() -> some View { + modifier(PublishDuoLayoutMode()) + } +} diff --git a/Tests/DuoLayoutKitTests/DuoLayoutKitTests.swift b/Tests/DuoLayoutKitTests/DuoLayoutKitTests.swift deleted file mode 100644 index 022ab91..0000000 --- a/Tests/DuoLayoutKitTests/DuoLayoutKitTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import DuoLayoutKit - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/Tests/DuoLayoutKitTests/DuoLayoutModeTests.swift b/Tests/DuoLayoutKitTests/DuoLayoutModeTests.swift new file mode 100644 index 0000000..a33ffe4 --- /dev/null +++ b/Tests/DuoLayoutKitTests/DuoLayoutModeTests.swift @@ -0,0 +1,45 @@ +import SwiftUI +import Testing +@testable import DuoLayoutKit + +@Suite("DuoLayoutMode derivation") +struct DuoLayoutModeTests { + + @Test("A regular horizontal size class is expanded") + func regularIsExpanded() { + #expect(DuoLayoutMode(horizontalSizeClass: .regular) == .expanded) + } + + @Test("A compact horizontal size class is compact") + func compactIsCompact() { + #expect(DuoLayoutMode(horizontalSizeClass: .compact) == .compact) + } + + @Test("A missing horizontal size class falls back to compact") + func nilIsCompact() { + #expect(DuoLayoutMode(horizontalSizeClass: nil) == .compact) + } + + @Test("isExpanded matches the case") + func isExpandedFlag() { + #expect(DuoLayoutMode.expanded.isExpanded) + #expect(!DuoLayoutMode.compact.isExpanded) + } + + @Test("The environment default is compact") + func environmentDefault() { + #expect(EnvironmentValues().duoLayoutMode == .compact) + } + + @Test("Publishing the mode into the environment overrides the default") + func environmentOverride() { + var environment = EnvironmentValues() + environment.duoLayoutMode = .expanded + #expect(environment.duoLayoutMode == .expanded) + } + + @Test("allCases covers both modes exactly once") + func caseIterable() { + #expect(DuoLayoutMode.allCases == [.compact, .expanded]) + } +}