├── Sources
├── SherlockForms
│ ├── _exported.swift
│ ├── UncheckedSendable.swift
│ ├── Internals
│ │ ├── String+Extension.swift
│ │ ├── LazyView.swift
│ │ ├── Collection+Safe.swift
│ │ ├── FloatingPoint+MaxFractionDigits.swift
│ │ ├── View+HideKeyboard.swift
│ │ ├── TextEditor.swift
│ │ ├── swiftui-navigation.swift
│ │ └── CopyableViewModifier.swift
│ ├── Types
│ │ ├── FormCopyableKeyValue.swift
│ │ ├── AnyViewModifier.swift
│ │ └── SherlockDate.swift
│ ├── Environment
│ │ ├── FormCellCopyableEnvironmentKey.swift
│ │ ├── FormCellIconWidthEnvironmentKey.swift
│ │ └── FormCellContentModifierEnvironmentKey.swift
│ ├── SherlockView.swift
│ ├── FormCells
│ │ ├── StackCell.swift
│ │ ├── ToggleCell.swift
│ │ ├── NavigationLinkCell.swift
│ │ ├── ButtonCell.swift
│ │ ├── DatePickerCell.swift
│ │ ├── TextFieldCell.swift
│ │ ├── TextCell.swift
│ │ ├── TextEditorCell.swift
│ │ ├── StepperCell.swift
│ │ ├── ContainerCell.swift
│ │ ├── CasePickerCell.swift
│ │ ├── ButtonDialogCell.swift
│ │ ├── ArrayPickerCell.swift
│ │ └── SliderCell.swift
│ ├── SherlockForm.swift
│ └── List
│ │ ├── SimpleList.swift
│ │ └── NestedList.swift
├── SherlockDebugForms
│ ├── _exported.swift
│ ├── Helpers
│ │ ├── Helper.swift
│ │ ├── AnimationSpeedHelper.swift
│ │ ├── UserDefaultsHelper.swift
│ │ └── FileHelper.swift
│ ├── Internals
│ │ ├── String+Extension.swift
│ │ ├── View+SectionHeader.swift
│ │ ├── UIView+CurrentFirstResponder.swift
│ │ └── swiftui-navigation.swift
│ ├── DeviceInfo
│ │ ├── GPU.swift
│ │ ├── System.swift
│ │ ├── Memory.swift
│ │ ├── CPU.swift
│ │ ├── Network.swift
│ │ └── Device.swift
│ ├── AppInfoView.swift
│ ├── AppInfo
│ │ ├── Application.swift
│ │ └── Directory.swift
│ ├── DeviceInfoView.swift
│ ├── UserDefaultsItemView.swift
│ └── UserDefaultsListView.swift
└── SherlockHUD
│ ├── UncheckedSendable.swift
│ ├── Internals
│ ├── HUDPresentation.swift
│ └── HUD.swift
│ ├── ShowHUDEnvironmentKey.swift
│ ├── HUDAlignment.swift
│ ├── HUDMessage.swift
│ └── EnableHUDViewModifier.swift
├── Examples
├── SherlockHUD-Demo.swiftpm
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── MyApp.swift
│ ├── Constant.swift
│ ├── Package.swift
│ └── RootView.swift
├── SherlockForms-Gallery.swiftpm
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── CustomView.swift
│ ├── UserDefaultsKey.swift
│ ├── ListView.swift
│ ├── Constant.swift
│ ├── NestedListView.swift
│ ├── Package.swift
│ ├── MyApp.swift
│ └── RootView.swift
└── SherlockForms-Gallery-iOS14.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── project.pbxproj
├── Makefile
├── .github
└── workflows
│ └── main.yml
├── LICENSE
├── Package.swift
├── .gitignore
└── README.md
/Sources/SherlockForms/_exported.swift:
--------------------------------------------------------------------------------
1 | @_exported import SherlockHUD
2 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/_exported.swift:
--------------------------------------------------------------------------------
1 | @_exported import SherlockForms
2 |
3 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Helpers/Helper.swift:
--------------------------------------------------------------------------------
1 | /// Namespace for helper functions.
2 | public enum Helper {}
3 |
--------------------------------------------------------------------------------
/Examples/SherlockHUD-Demo.swiftpm/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/UncheckedSendable.swift:
--------------------------------------------------------------------------------
1 | // TODO: Remove `@unchecked Sendable` when `Sendable` is supported by each module.
2 |
3 | import SwiftUI
4 |
5 | @available(iOS 15.0, *)
6 | extension ButtonRole: @unchecked Sendable {}
7 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery-iOS14.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/String+Extension.swift:
--------------------------------------------------------------------------------
1 | extension String
2 | {
3 | func truncated(maxCount: Int, trailing: String = "…") -> String
4 | {
5 | (self.count > maxCount) ? self.prefix(maxCount) + trailing : self
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Examples/SherlockHUD-Demo.swiftpm/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Internals/String+Extension.swift:
--------------------------------------------------------------------------------
1 | extension String
2 | {
3 | func truncated(maxCount: Int, trailing: String = "…") -> String
4 | {
5 | (self.count > maxCount) ? self.prefix(maxCount) + trailing : self
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Helpers/AnimationSpeedHelper.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | extension Helper
4 | {
5 | @MainActor
6 | public static func setAnimationSpeed(_ speed: Float)
7 | {
8 | UIApplication.shared.windows.first?.layer.speed = speed
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Sources/SherlockHUD/UncheckedSendable.swift:
--------------------------------------------------------------------------------
1 | // TODO: Remove `@unchecked Sendable` when `Sendable` is supported by each module.
2 |
3 | import SwiftUI
4 |
5 | extension AnyView: @unchecked Sendable {}
6 | extension Binding: @unchecked Sendable {}
7 |
8 | extension UUID: @unchecked Sendable {}
9 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Internals/View+SectionHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View
4 | {
5 | @ViewBuilder
6 | func sectionHeaderView(_ text: String) -> some View
7 | {
8 | if text.isEmpty { EmptyView() }
9 | else { Text(text) }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Examples/SherlockHUD-Demo.swiftpm/MyApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import SherlockHUD
3 |
4 | @main
5 | struct MyApp: App
6 | {
7 | var body: some Scene
8 | {
9 | WindowGroup {
10 | NavigationView {
11 | RootView()
12 | }
13 | .enableSherlockHUD(true)
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Helpers/UserDefaultsHelper.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Helper
4 | {
5 | public static func deleteUserDefaults()
6 | {
7 | guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return }
8 | UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery-iOS14.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/DeviceInfo/GPU.swift:
--------------------------------------------------------------------------------
1 | import Metal
2 |
3 | /// Originally from https://github.com/noppefoxwolf/DebugMenu
4 | class GPU {
5 | static var current: GPU = .init()
6 | let device: MTLDevice
7 |
8 | init() {
9 | device = MTLCreateSystemDefaultDevice()!
10 | }
11 |
12 | var currentAllocatedSize: Int {
13 | device.currentAllocatedSize
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/LazyView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// SwiftUI needs laziness, especially in `NavigationLink`.
4 | public struct LazyView: View
5 | {
6 | let content: () -> Content
7 |
8 | init(_ content: @autoclosure @escaping () -> Content)
9 | {
10 | self.content = content
11 | }
12 |
13 | public var body: Content
14 | {
15 | content()
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Types/FormCopyableKeyValue.swift:
--------------------------------------------------------------------------------
1 | /// "Copy Key" / "Copy Value" pair.
2 | /// - Note: If `key` or `value` is `nil`, the remaining string will be used as canonical "Copy".
3 | public struct FormCellCopyableKeyValue
4 | {
5 | public var key: String?
6 | public var value: String?
7 |
8 | public init(key: String? = nil, value: String? = nil)
9 | {
10 | self.key = key
11 | self.value = value
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | DESTINATION := -destination 'platform=iOS Simulator,name=iPhone 13 Pro'
2 |
3 | .PHONY: build-SherlockForms-Gallery
4 | build-SherlockForms-Gallery:
5 | cd Examples/SherlockForms-Gallery.swiftpm && \
6 | xcodebuild build -scheme SherlockForms-Gallery $(DESTINATION) | xcpretty
7 |
8 | .PHONY: build-SherlockHUD-Demo
9 | build-SherlockHUD-Demo:
10 | cd Examples/SherlockHUD-Demo.swiftpm && \
11 | xcodebuild build -scheme SherlockHUD-Demo $(DESTINATION) | xcpretty
12 |
--------------------------------------------------------------------------------
/Sources/SherlockHUD/Internals/HUDPresentation.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// HUD presentation data structure.
4 | struct HUDPresentation: Hashable, Sendable
5 | {
6 | let id = UUID()
7 | let content: AnyView
8 | let duration: TimeInterval
9 |
10 | static func == (l: HUDPresentation, r: HUDPresentation) -> Bool
11 | {
12 | l.id == r.id
13 | }
14 |
15 | func hash(into hasher: inout Hasher)
16 | {
17 | hasher.combine(id)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/SherlockHUD/ShowHUDEnvironmentKey.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension EnvironmentValues
4 | {
5 | public var showHUD: @MainActor (HUDMessage) -> Void
6 | {
7 | get { self[ShowHUDEnvironmentKey.self] }
8 | set { self[ShowHUDEnvironmentKey.self] = newValue }
9 | }
10 | }
11 |
12 | // MARK: - Private
13 |
14 | private struct ShowHUDEnvironmentKey: EnvironmentKey
15 | {
16 | static let defaultValue: @MainActor (HUDMessage) -> Void = { _ in }
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/Examples/SherlockHUD-Demo.swiftpm/Constant.swift:
--------------------------------------------------------------------------------
1 | enum Constant
2 | {
3 | static let loremIpsum = """
4 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
5 | """
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/Collection+Safe.swift:
--------------------------------------------------------------------------------
1 | extension Collection
2 | {
3 | subscript (safe index: Index) -> Iterator.Element?
4 | {
5 | return self.startIndex <= index && index < self.endIndex
6 | ? self[index]
7 | : nil
8 | }
9 |
10 | subscript (safe range: R) -> SubSequence?
11 | where R.Bound == Index
12 | {
13 | let r = range.relative(to: self)
14 | return r.lowerBound >= self.startIndex && r.upperBound <= self.endIndex
15 | ? self[range]
16 | : nil
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/DeviceInfo/System.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Originally from https://github.com/noppefoxwolf/DebugMenu
4 | class System {
5 | static func uptime() -> time_t {
6 | var boottime = timeval()
7 | var mib: [Int32] = [CTL_KERN, KERN_BOOTTIME]
8 | var size = MemoryLayout.stride
9 |
10 | var now = time_t()
11 | var uptime: time_t = -1
12 |
13 | time(&now)
14 | if sysctl(&mib, 2, &boottime, &size, nil, 0) != -1 && boottime.tv_sec != 0 {
15 | uptime = now - boottime.tv_sec
16 | }
17 | return uptime
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Internals/UIView+CurrentFirstResponder.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | // Trying to find which text field is active ios
4 | // See https://stackoverflow.com/a/40352519/666371.
5 | extension UIView
6 | {
7 | private enum Static
8 | {
9 | weak static var responder: UIView?
10 | }
11 |
12 | static func currentFirstResponder() -> UIView?
13 | {
14 | Static.responder = nil
15 | UIApplication.shared.sendAction(#selector(UIView._trap), to: nil, from: nil, for: nil)
16 | return Static.responder
17 | }
18 |
19 | @objc private func _trap()
20 | {
21 | Static.responder = self
22 | }
23 | }
24 |
25 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Types/AnyViewModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct AnyViewModifier: ViewModifier
4 | {
5 | private let _body: (Content) -> AnyView
6 |
7 | public init(_ modifier: VM)
8 | {
9 | self._body = { AnyView($0.modifier(modifier)) }
10 | }
11 |
12 | public init(_ modify: @escaping (Content) -> Content2)
13 | where Content2: View
14 | {
15 | self._body = { AnyView(modify($0)) }
16 | }
17 |
18 | public init()
19 | {
20 | self._body = { AnyView($0) }
21 | }
22 |
23 | public func body(content: Content) -> some View
24 | {
25 | _body(content)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Environment/FormCellCopyableEnvironmentKey.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View
4 | {
5 | @MainActor
6 | public func formCellCopyable(_ isCopyable: Bool) -> some View
7 | {
8 | environment(\.formCellCopyable, isCopyable)
9 | }
10 | }
11 |
12 | // MARK: - FormCellCopyableEnvironmentKey
13 |
14 | private struct FormCellCopyableEnvironmentKey: EnvironmentKey
15 | {
16 | static let defaultValue: Bool = false
17 | }
18 |
19 | extension EnvironmentValues
20 | {
21 | var formCellCopyable: Bool
22 | {
23 | get { self[FormCellCopyableEnvironmentKey.self] }
24 | set { self[FormCellCopyableEnvironmentKey.self] = newValue }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Environment/FormCellIconWidthEnvironmentKey.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View
4 | {
5 | @MainActor
6 | public func formCellIconWidth(_ iconWidth: CGFloat?) -> some View
7 | {
8 | environment(\.formCellIconWidth, iconWidth)
9 | }
10 | }
11 |
12 | // MARK: - FormCellIconWidthEnvironmentKey
13 |
14 | private struct FormCellIconWidthEnvironmentKey: EnvironmentKey
15 | {
16 | static let defaultValue: CGFloat? = nil
17 | }
18 |
19 | extension EnvironmentValues
20 | {
21 | var formCellIconWidth: CGFloat?
22 | {
23 | get { self[FormCellIconWidthEnvironmentKey.self] }
24 | set { self[FormCellIconWidthEnvironmentKey.self] = newValue }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/FloatingPoint+MaxFractionDigits.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension BinaryFloatingPoint
4 | {
5 | func string(maxFractionDigits: Int? = nil) -> String
6 | {
7 | guard let maxFractionDigits = maxFractionDigits else {
8 | return String(Double(self))
9 | }
10 |
11 | let number = NSNumber(value: Double(self))
12 | numberFormatter.minimumFractionDigits = maxFractionDigits
13 | numberFormatter.maximumFractionDigits = maxFractionDigits
14 | return numberFormatter.string(from: number)!
15 | }
16 | }
17 |
18 | private let numberFormatter: NumberFormatter = {
19 | let formatter = NumberFormatter()
20 | formatter.numberStyle = .decimal
21 | return formatter
22 | }()
23 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/CustomView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct CustomView: View
4 | {
5 | var body: some View
6 | {
7 | VStack(spacing: 16) {
8 | Text("🕵️♂️").font(.system(size: 64))
9 | Text("Hello SherlockForms!").font(.title)
10 | Text("""
11 | `SherlockForms` is an elegant SwiftUI Form builder to create a searchable Settings screen and even DebugMenu for your app!
12 | """)
13 | .frame(alignment: .leading)
14 | }
15 | .padding()
16 | }
17 | }
18 |
19 | // MARK: - Previews
20 |
21 | struct CustomView_Previews: PreviewProvider
22 | {
23 | static var previews: some View
24 | {
25 | CustomView()
26 | }
27 | }
28 |
29 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions
2 | # https://github.com/actions/virtual-environments/blob/master/images/macos
3 | name: CI
4 |
5 | on:
6 | push:
7 | branches:
8 | - main
9 | - ci/**
10 | pull_request:
11 |
12 | env:
13 | DEVELOPER_DIR: /Applications/Xcode_13.2.1.app
14 |
15 | jobs:
16 | build-SherlockForms-Gallery:
17 | runs-on: macos-11
18 | steps:
19 | - uses: actions/checkout@v2
20 | - name: Build SherlockForms-Gallery
21 | run: make build-SherlockForms-Gallery
22 |
23 | build-SherlockHUD-Demo:
24 | runs-on: macos-11
25 | steps:
26 | - uses: actions/checkout@v2
27 | - name: Build SherlockHUD-Demo
28 | run: make build-SherlockHUD-Demo
29 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/DeviceInfo/Memory.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Originally from https://github.com/noppefoxwolf/DebugMenu
4 | class Memory {
5 | static func usage() -> UInt64 {
6 | var info = mach_task_basic_info()
7 | var count = UInt32(MemoryLayout.size(ofValue: info) / MemoryLayout.size)
8 | let result = withUnsafeMutablePointer(to: &info) {
9 | task_info(
10 | mach_task_self_, // FIXME: concurrency-unsafe
11 | task_flavor_t(MACH_TASK_BASIC_INFO),
12 | $0.withMemoryRebound(to: Int32.self, capacity: 1) {
13 | UnsafeMutablePointer($0)
14 | },
15 | &count
16 | )
17 | }
18 | return result == KERN_SUCCESS ? info.resident_size : 0
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/View+HideKeyboard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @MainActor
4 | func hideKeyboard()
5 | {
6 | let resign = #selector(UIResponder.resignFirstResponder)
7 | UIApplication.shared.sendAction(resign, to: nil, from: nil, for: nil)
8 | }
9 |
10 | extension UIView
11 | {
12 | // NOTE:
13 | // `UIView.touchesEnded` extension works better than `form.onTapGesture`
14 | // which causes some form UI to stop working.
15 | // cf. https://developer.apple.com/forums/thread/127196
16 | open override func touchesEnded(_ touches: Set, with event: UIEvent?)
17 | {
18 | super.touchesEnded(touches, with: event)
19 |
20 | let className = "\(self)"
21 | if className.contains("CellHostingView") && className.contains("SwiftUI") {
22 | hideKeyboard()
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/UserDefaultsKey.swift:
--------------------------------------------------------------------------------
1 | enum UserDefaultsBoolKey: String, CaseIterable
2 | {
3 | case lowPowerMode = "low-power-mode"
4 | case slowAnimation = "slow-animation"
5 | }
6 |
7 | enum UserDefaultsStringKey: String, CaseIterable
8 | {
9 | case username = "username"
10 | case email = "email"
11 | case password = "password"
12 | case status = "status"
13 | case languageSelection = "language"
14 | case testLongUserDefaults = "test-long-user-defaults"
15 | }
16 |
17 | enum UserDefaultsIntKey: String, CaseIterable
18 | {
19 | case languageIntSelection = "language-int"
20 | }
21 |
22 | enum UserDefaultsDoubleKey: String, CaseIterable
23 | {
24 | case speed = "speed"
25 | case fontSize = "font-size"
26 | }
27 |
28 | enum UserDefaultsDateKey: String, CaseIterable
29 | {
30 | case birthday = "birthday"
31 | case alarm = "alarm"
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/SherlockView.swift:
--------------------------------------------------------------------------------
1 | import CoreGraphics
2 |
3 | /// A protocol that interacts with ``SherlockForm``.
4 | @MainActor
5 | public protocol SherlockView
6 | {
7 | /// Current searching text.
8 | var searchText: String { get }
9 |
10 | /// Compares `keywords` with `searchText`.
11 | func canShowCell(keywords: [String]) -> Bool
12 | }
13 |
14 | // MARK: - Default implementation
15 |
16 | extension SherlockView
17 | {
18 | // Default implementation.
19 | public func canShowCell(keywords: [String]) -> Bool
20 | {
21 | if searchText.isEmpty { return true }
22 |
23 | for keyword in keywords {
24 | if keyword.lowercased().contains(searchText.lowercased()) {
25 | return true
26 | }
27 | }
28 |
29 | return false
30 | }
31 |
32 | public func canShowCell(keywords: String...) -> Bool
33 | {
34 | canShowCell(keywords: keywords)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/ListView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import SherlockForms
3 |
4 | /// Simple `SwiftUI.List` example.
5 | struct ListView: View, SherlockView
6 | {
7 | @State public private(set) var searchText: String = ""
8 |
9 | @State private var items: [ListItem] = (0 ... 3).map { ListItem(content: "Row \($0)") }
10 |
11 | var body: some View
12 | {
13 | SherlockForm(searchText: $searchText) {
14 | simpleList(
15 | data: items,
16 | rowContent: { item in
17 | Text("\(item.content)")
18 | }
19 | )
20 | }
21 | .navigationBarTitleDisplayMode(.inline)
22 | .formCellCopyable(true)
23 | }
24 | }
25 |
26 | // MARK: - Previews
27 |
28 | struct ListView_Previews: PreviewProvider
29 | {
30 | static var previews: some View
31 | {
32 | ListView()
33 | }
34 | }
35 |
36 | // MARK: - Private
37 |
38 | private struct ListItem: SimpleListItem, Identifiable
39 | {
40 | let content: String
41 |
42 | var id: String { content }
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Environment/FormCellContentModifierEnvironmentKey.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View
4 | {
5 | @MainActor
6 | public func formCellContentModifier(_ modifier: VM) -> some View
7 | where VM: ViewModifier
8 | {
9 | environment(\.formCellContentModifier, AnyViewModifier(modifier))
10 | }
11 |
12 | @MainActor
13 | public func formCellContentModifier(
14 | _ modify: @escaping (AnyViewModifier.Content) -> Content
15 | ) -> some View
16 | where Content: View
17 | {
18 | environment(\.formCellContentModifier, AnyViewModifier(modify))
19 | }
20 | }
21 |
22 | // MARK: - FormCellContentModifierEnvironmentKey
23 |
24 | private struct FormCellContentModifierEnvironmentKey: EnvironmentKey
25 | {
26 | static let defaultValue: AnyViewModifier = .init()
27 | }
28 |
29 | extension EnvironmentValues
30 | {
31 | var formCellContentModifier: AnyViewModifier
32 | {
33 | get { self[FormCellContentModifierEnvironmentKey.self] }
34 | set { self[FormCellContentModifierEnvironmentKey.self] = newValue }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Yasuhiro Inami
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/Constant.swift:
--------------------------------------------------------------------------------
1 | enum Constant
2 | {
3 | static let loremIpsum = """
4 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
5 | """
6 |
7 | static let languages: [String] = [
8 | "English",
9 | "Japanease",
10 | "French",
11 | "Chinese"
12 | ]
13 |
14 | enum Status: String, CaseIterable, Hashable {
15 | case online = "Online"
16 | case away = "Away"
17 | case offline = "Offline"
18 |
19 | init?(rawValue: String)
20 | {
21 | for case_ in Self.allCases {
22 | if case_.rawValue.lowercased() == rawValue.lowercased() {
23 | self = case_
24 | return
25 | }
26 | }
27 |
28 | return nil
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/TextEditor.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct TextEditorWithPlaceholder: View
4 | {
5 | @Binding var text: String
6 |
7 | private let placeholder: String
8 |
9 | @Environment(\.multilineTextAlignment)
10 | private var _placeholderAlignment: TextAlignment
11 |
12 | init(
13 | _ placeholder: String,
14 | text: Binding
15 | )
16 | {
17 | self._text = text
18 | self.placeholder = placeholder
19 | }
20 |
21 | var body: some View
22 | {
23 | ZStack {
24 | if text.isEmpty {
25 | Text(placeholder)
26 | .opacity(0.25)
27 | .frame(maxWidth: .infinity, alignment: placeholderAlignment)
28 | }
29 | TextEditor(text: $text)
30 | .padding(.horizontal, -4) // Remove TextEditor's extra padding.
31 | }
32 | }
33 |
34 | private var placeholderAlignment: Alignment
35 | {
36 | switch _placeholderAlignment {
37 | case .leading:
38 | return .leading
39 | case .center:
40 | return .center
41 | case .trailing:
42 | return .trailing
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Internals/swiftui-navigation.swift:
--------------------------------------------------------------------------------
1 | // Originally from https://github.com/pointfreeco/swiftui-navigation
2 | // with `internal` access modifier.
3 |
4 | import SwiftUI
5 |
6 | extension Binding {
7 | init?(unwrapping base: Binding) {
8 | guard let value = base.wrappedValue else {
9 | return nil
10 | }
11 |
12 | self = Binding(
13 | get: { value },
14 | set: { base.wrappedValue = $0 }
15 | )
16 | }
17 |
18 | func isPresent() -> Binding
19 | where Value == Wrapped? {
20 | .init(
21 | get: { self.wrappedValue != nil },
22 | set: { isPresent, transaction in
23 | if !isPresent {
24 | self.transaction(transaction).wrappedValue = nil
25 | }
26 | }
27 | )
28 | }
29 | }
30 |
31 | extension View {
32 | func sheet(
33 | unwrapping value: Binding,
34 | onDismiss: (() -> Void)? = nil,
35 | @ViewBuilder content: @escaping (Binding) -> Content
36 | ) -> some View
37 | where Content: View {
38 | self.sheet(isPresented: value.isPresent(), onDismiss: onDismiss) {
39 | Binding(unwrapping: value).map(content)
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/SherlockHUD/HUDAlignment.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Position of HUD presentation.
4 | public struct HUDAlignment: Hashable, Sendable
5 | {
6 | let kind: Kind
7 |
8 | public static let top: HUDAlignment = .init(kind: .top)
9 | public static let bottom: HUDAlignment = .init(kind: .bottom)
10 | public static let center: HUDAlignment = .init(kind: .center)
11 | }
12 |
13 | // MARK: - Internals
14 |
15 | extension HUDAlignment
16 | {
17 | enum Kind: Hashable, Sendable
18 | {
19 | case top
20 | case center
21 | case bottom
22 |
23 | var zstackAlignment: Alignment
24 | {
25 | switch self {
26 | case .top:
27 | return .top
28 | case .center:
29 | return .center
30 | case .bottom:
31 | return .bottom
32 | }
33 | }
34 |
35 | var preferredTransition: AnyTransition
36 | {
37 | switch self {
38 | case .top:
39 | return AnyTransition.move(edge: .top).combined(with: .opacity)
40 | case .bottom:
41 | return AnyTransition.move(edge: .bottom).combined(with: .opacity)
42 | case .center:
43 | return AnyTransition.scale(scale: 0.5).combined(with: .opacity)
44 | }
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Types/SherlockDate.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// `String`-representing `Date` wrapper, useful for storing as `UserDefaults` string via `@AppStorage`.
4 | public struct SherlockDate: RawRepresentable, Comparable
5 | {
6 | public var date: Date
7 |
8 | public init(_ date: Date = .init())
9 | {
10 | self.date = date
11 | }
12 |
13 | public init?(rawValue: String)
14 | {
15 | if let date = Self.formatter.date(from: rawValue) {
16 | self.date = date
17 | }
18 | else {
19 | return nil
20 | }
21 | }
22 |
23 | public var rawValue: String
24 | {
25 | Self.formatter.string(from: date)
26 | }
27 |
28 | public static func == (l: Self, r: Self) -> Bool
29 | {
30 | l.date == r.date
31 | }
32 |
33 | public static func < (l: Self, r: Self) -> Bool
34 | {
35 | l.date < r.date
36 | }
37 |
38 | private static let formatter: ISO8601DateFormatter = {
39 | let formatter = ISO8601DateFormatter()
40 | formatter.timeZone = .autoupdatingCurrent
41 | return formatter
42 | }()
43 | }
44 |
45 | // MARK: - Binding
46 |
47 | extension Binding where Value == SherlockDate
48 | {
49 | public var date: Binding
50 | {
51 | .init(
52 | get: { wrappedValue.date },
53 | set: { wrappedValue = SherlockDate($0) }
54 | )
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/Helpers/FileHelper.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Helper
4 | {
5 | public static func deleteAllFilesAndCaches() throws
6 | {
7 | try deleteAllCaches()
8 |
9 | try deleteDirectoryContents(at: .library)
10 | try deleteDirectoryContents(at: .applicationSupport)
11 | try deleteDirectoryContents(at: .document)
12 | }
13 |
14 | public static func deleteAllCaches() throws
15 | {
16 | URLCache.shared.removeAllCachedResponses()
17 |
18 | try deleteDirectoryContents(at: .caches)
19 | try deleteDirectoryContents(at: .tmp)
20 | }
21 |
22 | public static func deleteDirectoryContents(at directory: AppleDirectory) throws
23 | {
24 | try deleteDirectoryContents(atPath: directory.path)
25 | }
26 |
27 | // MARK: - Private
28 |
29 | private static func deleteDirectoryContents(atPath path: String) throws
30 | {
31 | let subdirectories = try FileManager.default.contentsOfDirectory(atPath: path)
32 | for subdirectory in subdirectories {
33 | let deletingURL = URL(fileURLWithPath: path).appendingPathComponent(subdirectory)
34 | do {
35 | try FileManager.default.removeItem(at: deletingURL)
36 | // print("[SUCCESS] deleteDirectoryContents at \(deletingURL)")
37 | }
38 | catch {
39 | #if DEBUG
40 | print("[ERROR] deleteDirectoryContents:", error, "at \(deletingURL)")
41 | #endif
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/swiftui-navigation.swift:
--------------------------------------------------------------------------------
1 | // Originally from https://github.com/pointfreeco/swiftui-navigation
2 | // with `internal` access modifier.
3 |
4 | import SwiftUI
5 |
6 | extension Binding {
7 | init?(unwrapping base: Binding) {
8 | guard let value = base.wrappedValue else {
9 | return nil
10 | }
11 |
12 | self = Binding(
13 | get: { value },
14 | set: { base.wrappedValue = $0 }
15 | )
16 | }
17 |
18 | func isPresent() -> Binding
19 | where Value == Wrapped? {
20 | .init(
21 | get: { self.wrappedValue != nil },
22 | set: { isPresent, transaction in
23 | if !isPresent {
24 | self.transaction(transaction).wrappedValue = nil
25 | }
26 | }
27 | )
28 | }
29 | }
30 |
31 | #if compiler(>=5.5)
32 | extension View {
33 | @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
34 | func confirmationDialog(
35 | title: (Value) -> Text,
36 | titleVisibility: Visibility = .automatic,
37 | unwrapping value: Binding,
38 | @ViewBuilder actions: @escaping (Value) -> A,
39 | @ViewBuilder message: @escaping (Value) -> M
40 | ) -> some View {
41 | self.confirmationDialog(
42 | value.wrappedValue.map(title) ?? Text(""),
43 | isPresented: value.isPresent(),
44 | titleVisibility: titleVisibility,
45 | presenting: value.wrappedValue,
46 | actions: actions,
47 | message: message
48 | )
49 | }
50 | }
51 | #endif
52 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/StackCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | /// `HStackCell` that can become visible or hidden depending on `searchText`.
8 | @ViewBuilder
9 | public func hstackCell(
10 | keywords: String...,
11 | copyableKeyValue: FormCellCopyableKeyValue? = nil,
12 | alignment: VerticalAlignment = .center,
13 | @ViewBuilder content: @escaping () -> Content
14 | ) -> HStackCell
15 | {
16 | ContainerCell, Content>(
17 | keywords: keywords,
18 | canShowCell: canShowCell,
19 | copyableKeyValue: copyableKeyValue,
20 | alignment: alignment,
21 | content: content
22 | )
23 | }
24 |
25 | /// `VStackCell` that can become visible or hidden depending on `searchText`.
26 | @ViewBuilder
27 | public func vstackCell(
28 | keywords: String...,
29 | copyableKeyValue: FormCellCopyableKeyValue? = nil,
30 | alignment: HorizontalAlignment = .leading,
31 | @ViewBuilder content: @escaping () -> Content
32 | ) -> VStackCell
33 | {
34 | VStackCell(
35 | keywords: keywords,
36 | canShowCell: canShowCell,
37 | copyableKeyValue: copyableKeyValue,
38 | alignment: alignment,
39 | content: content
40 | )
41 | }
42 | }
43 |
44 | // MARK: - HStackCell / VStackCell
45 |
46 | public typealias HStackCell = ContainerCell, Content>
47 | public typealias VStackCell = ContainerCell, Content>
48 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "SherlockForms",
7 | platforms: [.iOS(.v14)], // FIXME: macOS?
8 | products: [
9 | .library(
10 | name: "SherlockForms",
11 | targets: ["SherlockForms"]
12 | ),
13 | .library(
14 | name: "SherlockDebugForms",
15 | targets: ["SherlockDebugForms"]
16 | ),
17 | .library(
18 | name: "SherlockHUD",
19 | targets: ["SherlockHUD"]
20 | ),
21 | ],
22 | dependencies: [],
23 | targets: [
24 | .target(
25 | name: "SherlockHUD",
26 | dependencies: [],
27 | swiftSettings: [
28 | .unsafeFlags([
29 | "-Xfrontend", "-warn-concurrency",
30 | "-Xfrontend", "-enable-actor-data-race-checks",
31 | ])
32 | ]
33 | ),
34 | .target(
35 | name: "SherlockForms",
36 | dependencies: ["SherlockHUD"],
37 | swiftSettings: [
38 | .unsafeFlags([
39 | "-Xfrontend", "-warn-concurrency",
40 | "-Xfrontend", "-enable-actor-data-race-checks",
41 | ])
42 | ]
43 | ),
44 | .target(
45 | name: "SherlockDebugForms",
46 | dependencies: ["SherlockForms"],
47 | swiftSettings: [
48 | .unsafeFlags([
49 | "-Xfrontend", "-warn-concurrency",
50 | "-Xfrontend", "-enable-actor-data-race-checks",
51 | ])
52 | ]
53 | ),
54 | ]
55 | )
56 |
--------------------------------------------------------------------------------
/Examples/SherlockHUD-Demo.swiftpm/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.5
2 |
3 | // WARNING:
4 | // This file is automatically generated.
5 | // Do not edit it by hand because the contents will be replaced.
6 |
7 | import PackageDescription
8 | import AppleProductTypes
9 |
10 | let package = Package(
11 | name: "SherlockHUD-Demo",
12 | platforms: [
13 | .iOS("14.0")
14 | ],
15 | products: [
16 | .iOSApplication(
17 | name: "SherlockHUD-Demo",
18 | targets: ["AppModule"],
19 | bundleIdentifier: "com.inamiy.SherlockHUD-Demo",
20 | teamIdentifier: "UMBZ5WL247",
21 | displayVersion: "1.0",
22 | bundleVersion: "1",
23 | iconAssetName: "AppIcon",
24 | accentColorAssetName: "AccentColor",
25 | supportedDeviceFamilies: [
26 | .pad,
27 | .phone
28 | ],
29 | supportedInterfaceOrientations: [
30 | .portrait,
31 | .landscapeRight,
32 | .landscapeLeft,
33 | .portraitUpsideDown(.when(deviceFamilies: [.pad]))
34 | ]
35 | )
36 | ],
37 | dependencies: [
38 | // .package(url: "https://github.com/inamiy/SherlockForms", from: "0.1.0")
39 | .package(name: "SherlockForms", path: "../../")
40 | ],
41 | targets: [
42 | .executableTarget(
43 | name: "AppModule",
44 | dependencies: [
45 | .productItem(name: "SherlockHUD", package: "SherlockForms", condition: nil)
46 | ],
47 | path: ".",
48 | swiftSettings: [.unsafeFlags(["-warn-concurrency"], .when(configuration: .debug))]
49 | )
50 | ]
51 |
52 | )
53 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/DeviceInfo/CPU.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Originally from https://github.com/noppefoxwolf/DebugMenu
4 | class CPU {
5 | static func usage() -> Double {
6 | let ids = threadIDs()
7 | var totalUsage: Double = 0
8 | for id in ids {
9 | let usage = threadUsage(id: id)
10 | totalUsage += usage
11 | }
12 | return totalUsage
13 | }
14 |
15 | static func threadIDs() -> [thread_inspect_t] {
16 | var threadList: thread_act_array_t?
17 | var threadCount = UInt32(
18 | MemoryLayout.size / MemoryLayout.size
19 | )
20 | let result = task_threads(mach_task_self_, &threadList, &threadCount)
21 | if result != KERN_SUCCESS { return [] }
22 | var ids: [thread_inspect_t] = []
23 | for index in (0.. Double {
30 | var threadInfo = thread_basic_info()
31 | var threadInfoCount = UInt32(THREAD_INFO_MAX)
32 | let result = withUnsafeMutablePointer(to: &threadInfo) {
33 | $0.withMemoryRebound(to: integer_t.self, capacity: 1) {
34 | thread_info(id, UInt32(THREAD_BASIC_INFO), $0, &threadInfoCount)
35 | }
36 | }
37 | // スレッド情報が取れない = 該当スレッドのCPU使用率を0とみなす(基本nilが返ることはない)
38 | if result != KERN_SUCCESS { return 0 }
39 | let isIdle = threadInfo.flags == TH_FLAGS_IDLE
40 | // CPU使用率がスケール調整済みのため`TH_USAGE_SCALE`で除算し戻す
41 | return !isIdle ? Double(threadInfo.cpu_usage) / Double(TH_USAGE_SCALE) : 0
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/NestedListView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import SherlockForms
3 |
4 | /// Hierarchical `SwiftUI.List` example.
5 | struct NestedListView: View, SherlockView
6 | {
7 | @State public private(set) var searchText: String = ""
8 |
9 | @State private var items: [ListItem] = ListItem.presetItems
10 |
11 | var body: some View
12 | {
13 | SherlockForm(searchText: $searchText) {
14 | nestedList(
15 | data: items,
16 | rowContent: { item in
17 | Text("\(item.content)")
18 | }
19 | )
20 | }
21 | .navigationBarTitleDisplayMode(.inline)
22 | .formCellCopyable(true)
23 | }
24 | }
25 |
26 | // MARK: - Previews
27 |
28 | struct NestedListView_Previews: PreviewProvider
29 | {
30 | static var previews: some View
31 | {
32 | NestedListView()
33 | }
34 | }
35 |
36 | // MARK: - Private
37 |
38 | private struct ListItem: NestedListItem, Identifiable
39 | {
40 | let content: String
41 | var children: [ListItem]?
42 |
43 | var id: String { content }
44 |
45 | init(content: String, children: [ListItem]?)
46 | {
47 | self.content = content
48 | self.children = children
49 | }
50 |
51 | static let presetItems: [ListItem] = (0 ... 3).map { i in
52 | ListItem(
53 | content: "Row \(i)",
54 | children: (0 ... 3).map { j in
55 | ListItem(
56 | content: "Row \(i)-\(j)",
57 | children: (0 ... 3).map { k in
58 | ListItem(content: "Row \(i)-\(j)-\(k)", children: nil)
59 | }
60 | )
61 | }
62 | )
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.5
2 |
3 | // WARNING:
4 | // This file is automatically generated.
5 | // Do not edit it by hand because the contents will be replaced.
6 |
7 | import PackageDescription
8 | import AppleProductTypes
9 |
10 | let package = Package(
11 | name: "SherlockForms-Gallery",
12 | platforms: [
13 | .iOS("14.0")
14 | ],
15 | products: [
16 | .iOSApplication(
17 | name: "SherlockForms-Gallery",
18 | targets: ["AppModule"],
19 | bundleIdentifier: "com.inamiy.SherlockForms-Gallery",
20 | teamIdentifier: "UMBZ5WL247",
21 | displayVersion: "1.0",
22 | bundleVersion: "1",
23 | iconAssetName: "AppIcon",
24 | accentColorAssetName: "AccentColor",
25 | supportedDeviceFamilies: [
26 | .pad,
27 | .phone
28 | ],
29 | supportedInterfaceOrientations: [
30 | .portrait,
31 | .landscapeRight,
32 | .landscapeLeft,
33 | .portraitUpsideDown(.when(deviceFamilies: [.pad]))
34 | ]
35 | )
36 | ],
37 | dependencies: [
38 | // .package(url: "https://github.com/inamiy/SherlockForms", from: "0.1.0")
39 | .package(name: "SherlockForms", path: "../../")
40 | ],
41 | targets: [
42 | .executableTarget(
43 | name: "AppModule",
44 | dependencies: [
45 | .productItem(name: "SherlockDebugForms", package: "SherlockForms", condition: nil)
46 | ],
47 | path: ".",
48 | swiftSettings: [.unsafeFlags(["-warn-concurrency"], .when(configuration: .debug))]
49 | )
50 | ]
51 |
52 | )
53 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/Internals/CopyableViewModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// `ViewModifier` for showing "Copy (key & value)" context-menu.
4 | @MainActor
5 | struct CopyableViewModifier: ViewModifier
6 | {
7 | private let key: String?
8 | private let value: String?
9 |
10 | @Environment(\.showHUD)
11 | private var showHUD: @MainActor (HUDMessage) -> Void
12 |
13 | init(key: String? = nil, value: String? = nil)
14 | {
15 | self.key = key
16 | self.value = value
17 | }
18 |
19 | func body(content: Content) -> some View
20 | {
21 | if key == nil && value == nil {
22 | content
23 | }
24 | else {
25 | content.contextMenu {
26 | if let key = key, let value = value {
27 | Button { copyString(key) } label: {
28 | Label("Copy Key", systemImage: "doc.on.doc")
29 | }
30 |
31 | Button { copyString(value) } label: {
32 | Label("Copy Value", systemImage: "doc.on.doc")
33 | }
34 | }
35 | else {
36 | // NOTE: Should not reach empty string.
37 | Button { copyString(key ?? value ?? "") } label: {
38 | Label("Copy", systemImage: "doc.on.doc")
39 | }
40 | }
41 | }
42 | }
43 | }
44 |
45 | private func copyString(_ string: String)
46 | {
47 | UIPasteboard.general.string = string
48 |
49 | showHUD(
50 | .init(
51 | message: "Copied \"\(string.truncated(maxCount: 50))\"",
52 | duration: 2,
53 | alignment: .bottom
54 | )
55 | )
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/SherlockHUD/HUDMessage.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// `HUDPresentation` with `alignment`, used for `@Environment(\.showHUD)`.
4 | public struct HUDMessage: Hashable, Sendable
5 | {
6 | /// - Note: `nil` as dismissal.
7 | let presentation: HUDPresentation?
8 |
9 | let alignment: HUDAlignment
10 |
11 | private init(
12 | presentation: HUDPresentation?,
13 | alignment: HUDAlignment
14 | )
15 | {
16 | self.presentation = presentation
17 | self.alignment = alignment
18 | }
19 |
20 | /// Initializer with `@ViewBuilder`.
21 | public init(
22 | duration: TimeInterval = 2,
23 | alignment: HUDAlignment = .bottom,
24 | @ViewBuilder content: () -> Content
25 | )
26 | where Content: View
27 | {
28 | self.init(
29 | presentation: .init(content: AnyView(content()), duration: duration),
30 | alignment: alignment
31 | )
32 | }
33 |
34 | /// Message string initializer.
35 | public init(
36 | message: String,
37 | duration: TimeInterval = 2,
38 | alignment: HUDAlignment = .bottom
39 | )
40 | {
41 | self.init(
42 | duration: duration,
43 | alignment: alignment,
44 | content: { Text(message) }
45 | )
46 | }
47 |
48 | /// Loading initializer.
49 | public static func loading(
50 | message: String,
51 | duration: TimeInterval = 2,
52 | alignment: HUDAlignment = .bottom
53 | ) -> HUDMessage
54 | {
55 | self.init(
56 | duration: duration,
57 | alignment: alignment,
58 | content: { ProgressView(message) }
59 | )
60 | }
61 |
62 | /// Dismissal initializer.
63 | public static func dismiss(alignment: HUDAlignment) -> HUDMessage
64 | {
65 | self.init(presentation: nil, alignment: alignment)
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/AppInfoView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Screen for presenting Application-level information, e.g. App name, build version, bundler-identifier.
4 | public struct AppInfoView: View
5 | {
6 | @State public private(set) var searchText: String = ""
7 |
8 | public init() {}
9 |
10 | public var body: some View
11 | {
12 | SherlockForm(searchText: $searchText) {
13 | AppInfoSectionsView(searchText: searchText)
14 | }
15 | .formCellCopyable(true)
16 | .navigationTitle("App Info")
17 | .navigationBarTitleDisplayMode(.inline)
18 | }
19 | }
20 |
21 | /// AppInfo `Section`s, useful for presenting search results from ancestor screens.
22 | public struct AppInfoSectionsView: View, SherlockView
23 | {
24 | public let searchText: String
25 | private let sectionHeader: (String) -> String
26 |
27 | public init(
28 | searchText: String,
29 | sectionHeader: @escaping (String) -> String = { $0 }
30 | )
31 | {
32 | self.searchText = searchText
33 | self.sectionHeader = sectionHeader
34 | }
35 |
36 | public var body: some View
37 | {
38 | Section {
39 | textCell(title: "App Name", value: Application.current.appName)
40 | textCell(title: "Version", value: Application.current.version)
41 | textCell(title: "Build", value: Application.current.build)
42 | textCell(title: "Bundle ID", value: Application.current.bundleIdentifier)
43 | textCell(title: "App Size", value: Application.current.size)
44 | textCell(title: "Locale", value: Application.current.locale)
45 | textCell(title: "Localization", value: Application.current.preferredLocalizations)
46 | textCell(title: "TestFlight?", value: Application.current.isTestFlight)
47 | } header: {
48 | sectionHeaderView(sectionHeader(""))
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/ToggleCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func toggleCell(
9 | icon: Image? = nil,
10 | title: String,
11 | isOn: Binding
12 | ) -> ToggleCell
13 | {
14 | ToggleCell(
15 | icon: icon,
16 | title: title,
17 | isOn: isOn,
18 | canShowCell: canShowCell
19 | )
20 | }
21 | }
22 |
23 | // MARK: - ToggleCell
24 |
25 | @MainActor
26 | public struct ToggleCell: View
27 | {
28 | private let icon: Image?
29 | private let title: String
30 | private let isOn: Binding
31 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
32 |
33 | @Environment(\.formCellCopyable)
34 | private var isCopyable: Bool
35 |
36 | @Environment(\.formCellIconWidth)
37 | private var iconWidth: CGFloat?
38 |
39 | internal init(
40 | icon: Image? = nil,
41 | title: String,
42 | isOn: Binding,
43 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
44 | )
45 | {
46 | self.icon = icon
47 | self.title = title
48 | self.isOn = isOn
49 | self.canShowCell = canShowCell
50 | }
51 |
52 | public var body: some View
53 | {
54 | HStackCell(
55 | keywords: [title],
56 | canShowCell: canShowCell,
57 | copyableKeyValue: isCopyable ? .init(key: title, value: "\(isOn.wrappedValue)") : nil
58 | ) {
59 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
60 | Text(title)
61 |
62 | Spacer()
63 |
64 | Toggle(isOn: isOn) {
65 | EmptyView()
66 | }
67 | .labelsHidden()
68 | .onTapGesture { /* Don't let wrapper view to steal this tap */ }
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/SherlockForm.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Smart `.searchable` SwiftUI `Form`.
4 | /// - Note: `.searchable` is available only from iOS 15 or above, but still compiles for older OS versions.
5 | @MainActor
6 | public struct SherlockForm: View
7 | {
8 | private let searchText: Binding
9 | private let formBuilder: (() -> Content) -> AnyView
10 | private let content: () -> Content
11 |
12 | public init(
13 | searchText: Binding,
14 | formBuilder: @escaping (() -> Content) -> FormLike,
15 | @ViewBuilder content: @escaping () -> Content
16 | )
17 | {
18 | self.searchText = searchText
19 | self.formBuilder = { AnyView(formBuilder($0)) }
20 | self.content = content
21 | }
22 |
23 | /// Initializer with `formBuilder` being specialized to `Form`.
24 | public init(
25 | searchText: Binding,
26 | @ViewBuilder _ content: @escaping () -> Content
27 | )
28 | {
29 | self.init(
30 | searchText: searchText,
31 | formBuilder: { content in AnyView(Form { content() }) },
32 | content: content
33 | )
34 | }
35 |
36 | public var body: some View
37 | {
38 | let form = formBuilder { content() }
39 |
40 | if #available(iOS 15.0, *) {
41 | form
42 | .searchable(
43 | text: searchText,
44 | placement: .navigationBarDrawer(displayMode: .automatic),
45 | prompt: Text("Search")
46 | )
47 | .disableAutocorrection(true)
48 | .autocapitalization(.none)
49 | .onSubmit(of: .search) {
50 | hideKeyboard()
51 | }
52 | }
53 | else {
54 | // FIXME: No searching support for iOS 14 yet.
55 | form
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Sources/SherlockHUD/Internals/HUD.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View
4 | {
5 | @MainActor
6 | func hud(
7 | presentation: Binding,
8 | alignment: HUDAlignment,
9 | @ViewBuilder content: @escaping (HUDPresentation) -> Content
10 | ) -> some View
11 | {
12 | ZStack(alignment: alignment.kind.zstackAlignment) {
13 | self
14 |
15 | if let presentation_ = presentation.wrappedValue {
16 | let duration = presentation_.duration
17 |
18 | HUD(content: { content(presentation_) })
19 | .transition(alignment.kind.preferredTransition)
20 | .onAppear {
21 | Task {
22 | // Keep presenting for `duration`.
23 | try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
24 |
25 | // Then, hide if still in the same presentation.
26 | if presentation_ == presentation.wrappedValue {
27 | withAnimation(.easeInOut(duration: 0.25)) {
28 | presentation.wrappedValue = nil
29 | }
30 | }
31 | }
32 | }
33 | .zIndex(1)
34 | }
35 | }
36 | }
37 | }
38 |
39 | // MARK: - HUD
40 |
41 | // Originally from https://www.fivestars.blog/articles/swiftui-hud/
42 | @MainActor
43 | struct HUD: View
44 | {
45 | @ViewBuilder let content: () -> Content
46 |
47 | var body: some View
48 | {
49 | content()
50 | .padding(.horizontal, 12)
51 | .padding(16)
52 | .background(
53 | RoundedRectangle(cornerRadius: 24)
54 | .foregroundColor(Color.white)
55 | .shadow(color: Color(.black).opacity(0.16), radius: 12, x: 0, y: 5)
56 | )
57 | .padding(16)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Examples/SherlockHUD-Demo.swiftpm/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Examples/SherlockHUD-Demo.swiftpm/RootView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import SherlockHUD
3 |
4 | @MainActor
5 | struct RootView: View
6 | {
7 | /// - Note:
8 | /// Attaching `.enableSherlockHUD(true)` to topmost view will allow using `showHUD`.
9 | /// See `SherlockHUD` module for more information.
10 | @Environment(\.showHUD)
11 | private var showHUD: @MainActor (HUDMessage) -> Void
12 |
13 | var body: some View
14 | {
15 | VStack(spacing: 16) {
16 | Button("Top") {
17 | showHUD(randomHUDMessage(alignment: .top))
18 | }
19 | Button("Center") {
20 | showHUD(randomHUDMessage(alignment: .center))
21 | }
22 | Button("Bottom") {
23 | showHUD(randomHUDMessage(alignment: .bottom))
24 | }
25 | Button("Dismiss All") {
26 | showHUD(.dismiss(alignment: .top))
27 | showHUD(.dismiss(alignment: .center))
28 | showHUD(.dismiss(alignment: .bottom))
29 | }
30 | }
31 | .font(.largeTitle)
32 | }
33 |
34 | func randomHUDMessage(alignment: HUDAlignment) -> HUDMessage
35 | {
36 | let duration: TimeInterval = .random(in: 1 ... 3)
37 |
38 | let content: AnyView? = {
39 | switch (0 ... 2).randomElement()! {
40 | case 0: return AnyView(Text("Complete!"))
41 | case 1: return AnyView(Text(Constant.loremIpsum))
42 | case 2: return nil // Use `.loading`
43 | default: fatalError()
44 | }
45 | }()
46 |
47 | if let content = content {
48 | return .init(duration: duration, alignment: alignment, content: {
49 | content
50 | })
51 | }
52 | else {
53 | return .loading(message: "Loading", duration: duration, alignment: alignment)
54 | }
55 | }
56 | }
57 |
58 | // MARK: - Previews
59 |
60 | struct RootView_Previews: PreviewProvider
61 | {
62 | static var previews: some View
63 | {
64 | RootView()
65 | }
66 | }
67 |
68 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/NavigationLinkCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func navigationLinkCell(
9 | icon: Image? = nil,
10 | title: String,
11 | @ViewBuilder destination: @MainActor @escaping () -> Destination
12 | ) -> NavigationLinkCell
13 | where Destination: View
14 | {
15 | NavigationLinkCell(icon: icon, title: title, destination: destination, canShowCell: canShowCell)
16 | }
17 | }
18 |
19 | // MARK: - NavigationLinkCell
20 |
21 | @MainActor
22 | public struct NavigationLinkCell: View
23 | where Destination: View
24 | {
25 | private let icon: Image?
26 | private let title: String
27 | private let destination: @MainActor () -> Destination
28 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
29 |
30 | @Environment(\.formCellCopyable)
31 | private var isCopyable: Bool
32 |
33 | @Environment(\.formCellContentModifier)
34 | private var formCellContentModifier: AnyViewModifier
35 |
36 | @Environment(\.formCellIconWidth)
37 | private var iconWidth: CGFloat?
38 |
39 | internal init(
40 | icon: Image? = nil,
41 | title: String,
42 | @ViewBuilder destination: @MainActor @escaping () -> Destination,
43 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
44 | )
45 | {
46 | self.icon = icon
47 | self.title = title
48 | self.destination = destination
49 | self.canShowCell = canShowCell
50 | }
51 |
52 | public var body: some View
53 | {
54 | if canShowCell([title]) {
55 | _body
56 | }
57 | }
58 |
59 | @ViewBuilder
60 | private var _body: some View
61 | {
62 | let link = NavigationLink(destination: LazyView(destination()), label: {
63 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
64 | Text(title)
65 | })
66 | .modifier(formCellContentModifier)
67 |
68 | if isCopyable {
69 | link.modifier(CopyableViewModifier(key: title))
70 | }
71 | else {
72 | link
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/ButtonCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func buttonCell(
9 | icon: Image? = nil,
10 | title: String,
11 | action: @escaping () async throws -> Void
12 | ) -> ButtonCell
13 | {
14 | ButtonCell(
15 | icon: icon,
16 | title: title,
17 | action: action,
18 | canShowCell: canShowCell
19 | )
20 | }
21 | }
22 |
23 | // MARK: - ButtonCell
24 |
25 | @MainActor
26 | public struct ButtonCell: View
27 | {
28 | private let icon: Image?
29 | private let title: String
30 | private let action: () async throws -> Void
31 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
32 |
33 | @State private var isLoading: Bool = false
34 | @State private var currentTask: Task?
35 |
36 | @Environment(\.formCellCopyable)
37 | private var isCopyable: Bool
38 |
39 | @Environment(\.formCellIconWidth)
40 | private var iconWidth: CGFloat?
41 |
42 | internal init(
43 | icon: Image? = nil,
44 | title: String,
45 | action: @escaping () async throws -> Void,
46 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
47 | )
48 | {
49 | self.icon = icon
50 | self.title = title
51 | self.action = action
52 | self.canShowCell = canShowCell
53 | }
54 |
55 | public var body: some View
56 | {
57 | HStackCell(
58 | keywords: [title],
59 | canShowCell: canShowCell,
60 | copyableKeyValue: isCopyable ? .init(key: title) : nil
61 | ) {
62 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
63 | Button(title, action: {
64 | currentTask?.cancel()
65 | currentTask = Task {
66 | isLoading = true
67 | try await action()
68 | isLoading = false
69 | }
70 | })
71 |
72 | if isLoading {
73 | Spacer()
74 | ProgressView()
75 | .onTapGesture {
76 | currentTask?.cancel()
77 | isLoading = false
78 | }
79 | }
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/DatePickerCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | /// DatePicker cell.
8 | @ViewBuilder
9 | public func datePickerCell(
10 | icon: Image? = nil,
11 | title: String,
12 | selection: Binding,
13 | in bounds: ClosedRange = .distantPast ... .distantFuture,
14 | displayedComponents: DatePickerComponents
15 | ) -> some View
16 | {
17 | DatePickerCell(
18 | icon: icon,
19 | title: title,
20 | selection: selection,
21 | in: bounds,
22 | displayedComponents: displayedComponents,
23 | canShowCell: canShowCell
24 | )
25 | }
26 | }
27 |
28 | // MARK: - DatePickerCell
29 |
30 | public struct DatePickerCell: View
31 | {
32 | private let icon: Image?
33 | private let title: String
34 | private let selection: Binding
35 | private let bounds: ClosedRange
36 | private let displayedComponents: DatePickerComponents
37 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
38 |
39 | @Environment(\.formCellCopyable)
40 | private var isCopyable: Bool
41 |
42 | @Environment(\.formCellIconWidth)
43 | private var iconWidth: CGFloat?
44 |
45 | internal init(
46 | icon: Image? = nil,
47 | title: String,
48 | selection: Binding,
49 | in bounds: ClosedRange,
50 | displayedComponents: DatePickerComponents,
51 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
52 | )
53 | {
54 | self.icon = icon
55 | self.title = title
56 | self.selection = selection
57 | self.bounds = bounds
58 | self.displayedComponents = displayedComponents
59 | self.canShowCell = canShowCell
60 | }
61 |
62 | public var body: some View
63 | {
64 | HStackCell(
65 | keywords: [title, "\(selection.wrappedValue)"],
66 | canShowCell: canShowCell,
67 | copyableKeyValue: isCopyable ? .init(key: title, value: "\(SherlockDate(selection.wrappedValue).rawValue)") : nil
68 | ) {
69 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
70 | DatePicker(title, selection: selection, in: bounds, displayedComponents: displayedComponents)
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/TextFieldCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func textFieldCell(
9 | icon: Image? = nil,
10 | title: String? = nil,
11 | value: Binding,
12 | placeholder: String = "Input Value",
13 | modify: @escaping (TextField) -> Content
14 | ) -> TextFieldCell
15 | where Content: View
16 | {
17 | TextFieldCell(
18 | icon: icon,
19 | title: title,
20 | value: value,
21 | placeholder: placeholder,
22 | modify: modify,
23 | canShowCell: canShowCell
24 | )
25 | }
26 | }
27 |
28 | // MARK: - TextFieldCell
29 |
30 | @MainActor
31 | public struct TextFieldCell: View
32 | {
33 | private let icon: Image?
34 | private let title: String?
35 | private let value: Binding
36 | private let placeholder: String
37 | private let modify: (TextField) -> Content
38 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
39 |
40 | @Environment(\.formCellCopyable)
41 | private var isCopyable: Bool
42 |
43 | @Environment(\.formCellIconWidth)
44 | private var iconWidth: CGFloat?
45 |
46 | internal init(
47 | icon: Image? = nil,
48 | title: String?,
49 | value: Binding,
50 | placeholder: String,
51 | modify: @escaping (TextField) -> Content,
52 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
53 | )
54 | {
55 | self.icon = icon
56 | self.title = title
57 | self.value = value
58 | self.placeholder = placeholder
59 | self.modify = modify
60 | self.canShowCell = canShowCell
61 | }
62 |
63 | public var body: some View
64 | {
65 | HStackCell(
66 | keywords: [title, value.wrappedValue].compactMap { $0 },
67 | canShowCell: canShowCell,
68 | copyableKeyValue: isCopyable ? .init(key: title, value: value.wrappedValue) : nil
69 | ) {
70 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
71 | if let title = title {
72 | Text(title)
73 | Spacer(minLength: 16)
74 | }
75 | modify(TextField(placeholder, text: value))
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/TextCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func textCell(
9 | icon: Image? = nil,
10 | title: String,
11 | value: Any? = nil
12 | ) -> TextCell
13 | {
14 | TextCell(
15 | icon: icon,
16 | title: title,
17 | value: value,
18 | accessory: {},
19 | canShowCell: canShowCell
20 | )
21 | }
22 |
23 | @ViewBuilder
24 | public func textCell(
25 | icon: Image? = nil,
26 | title: String,
27 | value: Any? = nil,
28 | @ViewBuilder accessory: @escaping () -> Accessory
29 | ) -> TextCell
30 | where Accessory: View
31 | {
32 | TextCell(
33 | icon: icon,
34 | title: title,
35 | value: value,
36 | accessory: accessory,
37 | canShowCell: canShowCell
38 | )
39 | }
40 | }
41 |
42 | // MARK: - TextCell
43 |
44 | @MainActor
45 | public struct TextCell: View
46 | where Accessory: View
47 | {
48 | private let icon: Image?
49 | private let title: String
50 | private let value: String?
51 | private let accessory: () -> AnyView?
52 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
53 |
54 | @Environment(\.formCellCopyable)
55 | private var isCopyable: Bool
56 |
57 | @Environment(\.formCellIconWidth)
58 | private var iconWidth: CGFloat?
59 |
60 | internal init(
61 | icon: Image? = nil,
62 | title: String,
63 | value: Any?,
64 | @ViewBuilder accessory: @escaping () -> Accessory,
65 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
66 | )
67 | {
68 | self.icon = icon
69 | self.title = title
70 | self.value = value.map { "\($0)" }
71 | self.accessory = { AnyView(accessory()) }
72 | self.canShowCell = canShowCell
73 | }
74 |
75 | public var body: some View
76 | {
77 | HStackCell(
78 | keywords: [title, value].compactMap { $0 },
79 | canShowCell: canShowCell,
80 | copyableKeyValue: isCopyable ? .init(key: title, value: value) : nil
81 | ) {
82 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
83 | Text(title)
84 | Spacer()
85 | if let value = value {
86 | Text(value)
87 | }
88 | if let accessory = accessory() {
89 | accessory
90 | }
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/AppInfo/Application.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Originally from https://github.com/noppefoxwolf/DebugMenu
4 | public class Application {
5 | public static var current: Application = .init()
6 |
7 | public var appName: String {
8 | Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String
9 | }
10 |
11 | public var version: String {
12 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String
13 | }
14 |
15 | public var build: String {
16 | Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as! String
17 | }
18 |
19 | public var buildNumber: Int {
20 | Int(build) ?? 0
21 | }
22 |
23 | public var bundleIdentifier: String {
24 | Bundle.main.bundleIdentifier ?? ""
25 | }
26 |
27 | public var locale: String {
28 | Locale.current.identifier
29 | }
30 |
31 | public var preferredLocalizations: String {
32 | Bundle.main.preferredLocalizations.joined(separator: ",")
33 | }
34 |
35 | public var isTestFlight: Bool {
36 | #if DEBUG
37 | return false
38 | #else
39 | return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
40 | #endif
41 | }
42 |
43 | public var size: String {
44 | let byteCount = try? getByteCount()
45 | let formatter = ByteCountFormatter()
46 | formatter.countStyle = .file
47 | formatter.allowsNonnumericFormatting = false
48 | return formatter.string(fromByteCount: Int64(byteCount ?? 0))
49 | }
50 | }
51 |
52 | extension Application {
53 | public func getByteCount() throws -> UInt64 {
54 | let bundlePath = Bundle.main.bundlePath
55 | let documentPath = NSSearchPathForDirectoriesInDomains(
56 | .documentDirectory,
57 | .userDomainMask,
58 | true
59 | )[0]
60 | let libraryPath = NSSearchPathForDirectoriesInDomains(
61 | .libraryDirectory,
62 | .userDomainMask,
63 | true
64 | )[0]
65 | let tmpPath = NSTemporaryDirectory()
66 | return try [bundlePath, documentPath, libraryPath, tmpPath].map(getFileSize(atDirectory:))
67 | .reduce(0, +)
68 | }
69 |
70 | internal func getFileSize(atDirectory path: String) throws -> UInt64 {
71 | let files = try FileManager.default.subpathsOfDirectory(atPath: path)
72 | var fileSize: UInt64 = 0
73 | for file in files {
74 | let attributes = try FileManager.default.attributesOfItem(atPath: "\(path)/\(file)")
75 | fileSize += attributes[.size] as! UInt64
76 | }
77 | return fileSize
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/TextEditorCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func textEditorCell(
9 | icon: Image? = nil,
10 | title: String? = nil,
11 | value: Binding,
12 | placeholder: String = "Input Value",
13 | modify: @escaping (_ textEditor: AnyView) -> Content
14 | ) -> TextEditorCell
15 | where Content: View
16 | {
17 | TextEditorCell(
18 | icon: icon,
19 | title: title,
20 | value: value,
21 | placeholder: placeholder,
22 | modify: modify,
23 | canShowCell: canShowCell
24 | )
25 | }
26 |
27 | @ViewBuilder
28 | public func textEditorCell(
29 | icon: Image? = nil,
30 | title: String? = nil,
31 | value: Binding,
32 | placeholder: String = "Input Value"
33 | ) -> TextEditorCell
34 | {
35 | textEditorCell(
36 | icon: icon,
37 | title: title,
38 | value: value,
39 | placeholder: placeholder,
40 | modify: { $0 }
41 | )
42 | }
43 | }
44 |
45 | // MARK: - TextEditorCell
46 |
47 | @MainActor
48 | public struct TextEditorCell: View
49 | {
50 | private let icon: Image?
51 | private let title: String?
52 | private let value: Binding
53 | private let placeholder: String
54 | private let modify: (_ textEditor: AnyView) -> Content
55 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
56 |
57 | @Environment(\.formCellCopyable)
58 | private var isCopyable: Bool
59 |
60 | @Environment(\.formCellIconWidth)
61 | private var iconWidth: CGFloat?
62 |
63 | internal init(
64 | icon: Image? = nil,
65 | title: String?,
66 | value: Binding,
67 | placeholder: String,
68 | modify: @escaping (_ textEditor: AnyView) -> Content,
69 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
70 | )
71 | {
72 | self.icon = icon
73 | self.title = title
74 | self.value = value
75 | self.placeholder = placeholder
76 | self.modify = modify
77 | self.canShowCell = canShowCell
78 | }
79 |
80 | public var body: some View
81 | {
82 | HStackCell(
83 | keywords: [title, value.wrappedValue].compactMap { $0 },
84 | canShowCell: canShowCell,
85 | copyableKeyValue: isCopyable ? .init(key: title, value: value.wrappedValue) : nil
86 | ) {
87 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
88 | if let title = title {
89 | Text(title)
90 | Spacer(minLength: 16)
91 | }
92 | modify(AnyView(TextEditorWithPlaceholder(placeholder, text: value)))
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Sources/SherlockHUD/EnableHUDViewModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View
4 | {
5 | /// - Note: Developer should call this method at the topmost view.
6 | @MainActor
7 | @ViewBuilder
8 | public func enableSherlockHUD(_ isEnabled: Bool) -> some View
9 | {
10 | if isEnabled {
11 | self.modifier(EnableHUDViewModifier())
12 | }
13 | else {
14 | self
15 | }
16 | }
17 | }
18 |
19 | // MARK: - Internals
20 |
21 | /// Top / center / bottom HUD-presentation state storage & dispatcher.
22 | @MainActor
23 | struct EnableHUDViewModifier: ViewModifier
24 | {
25 | @State private var topHUDPresentation: HUDPresentation?
26 | @State private var centerHUDPresentation: HUDPresentation?
27 | @State private var bottomHUDPresentation: HUDPresentation?
28 |
29 | func body(content: Content) -> some View
30 | {
31 | content
32 | // Pass `showHUD` handler to child views.
33 | .environment(\.showHUD, { message in
34 | switch message.alignment.kind {
35 | case .top:
36 | showHUDAnimation(
37 | presentation: message.presentation,
38 | binding: $topHUDPresentation
39 | )
40 | case .center:
41 | showHUDAnimation(
42 | presentation: message.presentation,
43 | binding: $centerHUDPresentation
44 | )
45 | case .bottom:
46 | showHUDAnimation(
47 | presentation: message.presentation,
48 | binding: $bottomHUDPresentation
49 | )
50 | }
51 | })
52 | // Observe presentation changes for top / center / bottom HUD each.
53 | .hud(presentation: $topHUDPresentation, alignment: .top) { message in
54 | message.content
55 | }
56 | .hud(presentation: $centerHUDPresentation, alignment: .center) { message in
57 | message.content
58 | }
59 | .hud(presentation: $bottomHUDPresentation, alignment: .bottom) { message in
60 | message.content
61 | }
62 | }
63 |
64 | private func showHUDAnimation(
65 | presentation: HUDPresentation?,
66 | binding: Binding
67 | )
68 | {
69 | if let presentation = presentation {
70 | // Remove previous HUD immediately (no animation).
71 | binding.wrappedValue = nil
72 |
73 | // Show HUD after tick.
74 | Task {
75 | try await Task.sleep(nanoseconds: 1_000_000)
76 |
77 | withAnimation(.easeInOut(duration: 0.25)) {
78 | binding.wrappedValue = presentation
79 | }
80 | }
81 | }
82 | else {
83 | withAnimation(.easeInOut(duration: 0.25)) {
84 | binding.wrappedValue = nil
85 | }
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/StepperCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func stepperCell(
9 | icon: Image? = nil,
10 | title: String,
11 | value: Binding,
12 | in bounds: ClosedRange,
13 | step: Double = 1,
14 | maxFractionDigits: Int? = nil,
15 | valueString: @escaping (_ value: String) -> String = { $0 }
16 | ) -> StepperCell
17 | {
18 | StepperCell(
19 | icon: icon,
20 | title: title,
21 | value: value,
22 | in: bounds,
23 | step: step,
24 | maxFractionDigits: maxFractionDigits,
25 | valueString: valueString,
26 | canShowCell: canShowCell
27 | )
28 | }
29 | }
30 |
31 | // MARK: - StepperCell
32 |
33 | @MainActor
34 | public struct StepperCell: View
35 | {
36 | private let icon: Image?
37 | private let title: String
38 | @Binding private var value: Double
39 | private var bounds: ClosedRange
40 | private var step: Double
41 | private let maxFractionDigits: Int?
42 | private let valueString: (_ value: String) -> String
43 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
44 |
45 | @Environment(\.formCellCopyable)
46 | private var isCopyable: Bool
47 |
48 | @Environment(\.formCellIconWidth)
49 | private var iconWidth: CGFloat?
50 |
51 | internal init(
52 | icon: Image? = nil,
53 | title: String,
54 | value: Binding, // TODO: Binding where Value: Strideable
55 | in bounds: ClosedRange,
56 | step: Double = 1,
57 | maxFractionDigits: Int? = nil,
58 | valueString: @escaping (_ value: String) -> String = { $0 },
59 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
60 | )
61 | {
62 | self.icon = icon
63 | self.title = title
64 | self._value = value
65 | self.bounds = bounds
66 | self.step = step
67 | self.maxFractionDigits = maxFractionDigits
68 | self.valueString = valueString
69 | self.canShowCell = canShowCell
70 | }
71 |
72 | public var body: some View
73 | {
74 | let valueString_ = valueString("\(value.string(maxFractionDigits: maxFractionDigits))")
75 |
76 | HStackCell(
77 | keywords: [title, valueString_],
78 | canShowCell: canShowCell,
79 | copyableKeyValue: isCopyable ? .init(key: title, value: valueString_) : nil
80 | ) {
81 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
82 | Text(title)
83 | Spacer()
84 |
85 | // Value-printing.
86 | Text(valueString_)
87 | .font(.body.monospacedDigit())
88 |
89 | // NOTE: Set `label` as empty for customized text layout i.e. `title` and `valueString_`.
90 | Stepper(value: $value, in: bounds, step: step, label: {})
91 | .fixedSize() // Required to allow above `Spacer` to work correctly.
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/DeviceInfoView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import Combine
3 |
4 | /// Screen for presenting Device-level information, e.g. Device name, system version, disk usage.
5 | public struct DeviceInfoView: View, SherlockView
6 | {
7 | @State public private(set) var searchText: String = ""
8 |
9 | public init() {}
10 |
11 | public var body: some View
12 | {
13 | SherlockForm(searchText: $searchText) {
14 | DeviceInfoSectionsView(searchText: searchText)
15 | }
16 | .formCellCopyable(true)
17 | .navigationTitle("Device Info")
18 | .navigationBarTitleDisplayMode(.inline)
19 | }
20 | }
21 |
22 | /// DeviceInfo `Section`s, useful for presenting search results from ancestor screens.
23 | public struct DeviceInfoSectionsView: View, SherlockView
24 | {
25 | @StateObject private var timer: TimerWrapper = .init()
26 |
27 | public let searchText: String
28 | private let sectionHeader: (String) -> String
29 |
30 | public init(
31 | searchText: String,
32 | sectionHeader: @escaping (String) -> String = { $0 }
33 | )
34 | {
35 | self.searchText = searchText
36 | self.sectionHeader = sectionHeader
37 | }
38 |
39 | public var body: some View
40 | {
41 | Section {
42 | textCell(title: "Model", value: Device.current.localizedModel)
43 | textCell(title: "Name", value: Device.current.name)
44 | textCell(title: "System Name", value: Device.current.systemName)
45 | textCell(title: "System Version", value: Device.current.systemVersion)
46 | textCell(title: "Jailbreak?", value: Device.current.isJailbreaked)
47 | textCell(title: "Low Power Mode?", value: Device.current.isLowPowerModeEnabled)
48 | } header: {
49 | sectionHeaderView(sectionHeader("General"))
50 | }
51 |
52 | Section {
53 | textCell(title: "Processor", value: Device.current.processor)
54 | textCell(title: "Disk", value: Device.current.localizedDiskUsage)
55 | textCell(title: "Memory", value: "\(Device.current.localizedMemoryUsage) / \(Device.current.localizedPhysicalMemory)")
56 | textCell(title: "CPU", value: Device.current.localizedCPUUsage)
57 | textCell(title: "GPU Memory", value: Device.current.localizedGPUMemory)
58 | textCell(title: "Network", value: Device.current.networkUsage()?.prettyPrinted ?? "")
59 | textCell(title: "Battery", value: "\(Device.current.localizedBatteryLevel) / \(Device.current.localizedBatteryState)")
60 | textCell(title: "Thermal State", value: Device.current.localizedThermalState)
61 | } header: {
62 | sectionHeaderView(sectionHeader("Usage"))
63 | }
64 |
65 | Group {
66 | textCell(title: "System uptime", value: Device.current.localizedSystemUptime)
67 | textCell(title: "Uptime", value: Device.current.localizedUptime)
68 | }
69 | }
70 | }
71 |
72 | private final class TimerWrapper : ObservableObject
73 | {
74 | let objectWillChange = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
75 |
76 | init() {}
77 | }
78 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/ContainerCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - ContainerCell
4 |
5 | /// Form cell container that holds `keywords` and `canShowCell` to get filtered by ``SherlockForm``
6 | /// and also allows ContextMenu "Copy" when `copyableKeyValue` is set.
7 | @MainActor
8 | public struct ContainerCell: View
9 | {
10 | private let keywords: [String]
11 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
12 | private let copyableKeyValue: FormCellCopyableKeyValue?
13 | private let containerInit: (() -> Content) -> Container
14 | private let content: () -> Content
15 |
16 | @Environment(\.formCellContentModifier)
17 | private var formCellContentModifier: AnyViewModifier
18 |
19 | internal init(
20 | keywords: [String],
21 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true },
22 | copyableKeyValue: FormCellCopyableKeyValue?,
23 | containerInit: @escaping (() -> Content) -> Container,
24 | @ViewBuilder content: @escaping () -> Content
25 | )
26 | {
27 | self.keywords = keywords
28 | self.canShowCell = canShowCell
29 | self.copyableKeyValue = copyableKeyValue
30 | self.containerInit = containerInit
31 | self.content = content
32 | }
33 |
34 | /// Creates ``HStackCell``.
35 | internal init(
36 | keywords: [String],
37 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true },
38 | copyableKeyValue: FormCellCopyableKeyValue?,
39 | alignment: VerticalAlignment = .center,
40 | @ViewBuilder content: @escaping () -> Content
41 | )
42 | where Container == HStack
43 | {
44 | self.keywords = keywords
45 | self.canShowCell = canShowCell
46 | self.copyableKeyValue = copyableKeyValue
47 | self.containerInit = { HStack(alignment: alignment, content: $0) }
48 | self.content = content
49 | }
50 |
51 | /// Creates ``VStackCell``.
52 | internal init(
53 | keywords: [String],
54 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true },
55 | copyableKeyValue: FormCellCopyableKeyValue?,
56 | alignment: HorizontalAlignment = .leading,
57 | @ViewBuilder content: @escaping () -> Content
58 | )
59 | where Container == VStack
60 | {
61 | self.keywords = keywords
62 | self.canShowCell = canShowCell
63 | self.copyableKeyValue = copyableKeyValue
64 | self.containerInit = { VStack(alignment: alignment, content: $0) }
65 | self.content = content
66 | }
67 |
68 | public var body: some View
69 | {
70 | if canShowCell(keywords) {
71 | _body
72 | }
73 | }
74 |
75 | @ViewBuilder
76 | private var _body: some View
77 | {
78 | let container = containerInit { content() }
79 | .modifier(formCellContentModifier)
80 |
81 | if let copyableKeyValue = copyableKeyValue {
82 | container
83 | .modifier(CopyableViewModifier(key: copyableKeyValue.key, value: copyableKeyValue.value))
84 | }
85 | else {
86 | container
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Examples/SherlockForms-Gallery.swiftpm/MyApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import SherlockDebugForms
3 |
4 | private let isDebug = false
5 |
6 | @main
7 | struct MyApp: App
8 | {
9 | var body: some Scene
10 | {
11 | WindowGroup {
12 | NavigationView {
13 | if isDebug {
14 | // DEBUG: Shortcut presentation.
15 | UserDefaultsListView(
16 | editConfiguration: .init(
17 | boolKeys: Array(UserDefaultsBoolKey.allCases.map(\.rawValue)),
18 | stringKeys: Array(UserDefaultsStringKey.allCases.map(\.rawValue)),
19 | dateKeys: Array(UserDefaultsDateKey.allCases.map(\.rawValue)),
20 | intKeys: Array(UserDefaultsIntKey.allCases.map(\.rawValue)),
21 | doubleKeys: Array(UserDefaultsDoubleKey.allCases.map(\.rawValue))
22 | )
23 | )
24 | }
25 | else {
26 | RootView()
27 | }
28 | }
29 | .onAppear {
30 | guard isDebug else { return }
31 |
32 | // DEBUG: Insert intial UserDefaults values.
33 | UserDefaults.standard.set(
34 | "John Appleseed",
35 | forKey: UserDefaultsStringKey.username.rawValue
36 | )
37 |
38 | UserDefaults.standard.set(
39 | "john@example.com",
40 | forKey: UserDefaultsStringKey.email.rawValue
41 | )
42 |
43 | UserDefaults.standard.set(
44 | "admin",
45 | forKey: UserDefaultsStringKey.password.rawValue
46 | )
47 |
48 | UserDefaults.standard.set(
49 | Constant.languages[0],
50 | forKey: UserDefaultsStringKey.languageSelection.rawValue
51 | )
52 |
53 | // Index of `Constant.languages`.
54 | UserDefaults.standard.set(
55 | 0,
56 | forKey: UserDefaultsIntKey.languageIntSelection.rawValue
57 | )
58 |
59 | UserDefaults.standard.set(
60 | Constant.Status.away.rawValue,
61 | forKey: UserDefaultsStringKey.status.rawValue
62 | )
63 |
64 | UserDefaults.standard.set(
65 | true,
66 | forKey: UserDefaultsBoolKey.lowPowerMode.rawValue
67 | )
68 |
69 | UserDefaults.standard.set(
70 | 1.0,
71 | forKey: UserDefaultsDoubleKey.speed.rawValue
72 | )
73 |
74 | UserDefaults.standard.set(
75 | 12.0,
76 | forKey: UserDefaultsDoubleKey.fontSize.rawValue
77 | )
78 |
79 | UserDefaults.standard.set(
80 | Date().addingTimeInterval(-86400 * 365 * 20),
81 | forKey: UserDefaultsDateKey.birthday.rawValue
82 | )
83 |
84 | UserDefaults.standard.set(
85 | Date(),
86 | forKey: UserDefaultsDateKey.alarm.rawValue
87 | )
88 |
89 | // For testing long string in UserDefaults.
90 | UserDefaults.standard.set(
91 | Array(repeating: Constant.loremIpsum, count: 10).joined(separator: "\n"),
92 | forKey: UserDefaultsStringKey.testLongUserDefaults.rawValue
93 | )
94 | }
95 | .enableSherlockHUD(true)
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/List/SimpleList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | /// Creates a simple list that identifies its rows based on a key path to the identifier of the underlying data.
8 | @ViewBuilder
9 | public func simpleList(
10 | data: Data,
11 | id: KeyPath,
12 | @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent
13 | ) -> some View
14 | where
15 | Data: MutableCollection & RangeReplaceableCollection,
16 | Data.Element: SimpleListItem,
17 | ID: Hashable,
18 | RowContent: View
19 | {
20 | SimpleList(
21 | data: data,
22 | id: id,
23 | canShowCell: canShowCell,
24 | rowContent: rowContent
25 | )
26 | }
27 |
28 | /// Creates a simple list.
29 | @ViewBuilder
30 | public func simpleList(
31 | data: Data,
32 | @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent
33 | ) -> some View
34 | where
35 | Data: MutableCollection & RangeReplaceableCollection,
36 | Data.Element: SimpleListItem & Identifiable,
37 | RowContent: View
38 | {
39 | simpleList(
40 | data: data,
41 | id: \.id,
42 | rowContent: rowContent
43 | )
44 | }
45 | }
46 |
47 | // MARK: - SimpleList
48 |
49 | @MainActor
50 | private struct SimpleList: View
51 | where
52 | Data: MutableCollection & RangeReplaceableCollection,
53 | Data.Element: SimpleListItem,
54 | ID: Hashable,
55 | RowContent: View
56 | {
57 | private let data: Data
58 | private let id: KeyPath
59 |
60 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
61 |
62 | private let rowContent: (Data.Element) -> RowContent
63 |
64 | @Environment(\.formCellCopyable)
65 | private var isCopyable: Bool
66 |
67 | internal init(
68 | data: Data,
69 | id: KeyPath,
70 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool,
71 | @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent
72 | )
73 | {
74 | self.data = data
75 | self.id = id
76 | self.canShowCell = canShowCell
77 | self.rowContent = rowContent
78 | }
79 |
80 | public var body: some View
81 | {
82 | List(
83 | data.filter { canShowCell($0.getKeywords()) },
84 | id: id,
85 | rowContent: { item in
86 | if isCopyable, let copyableKeyValue = item.getFormCellCopyableKeyValue() {
87 | rowContent(item)
88 | .modifier(CopyableViewModifier(key: copyableKeyValue.key, value: copyableKeyValue.value))
89 | }
90 | else {
91 | rowContent(item)
92 | }
93 | }
94 | )
95 | }
96 | }
97 |
98 | // MARK: - SimpleListItem
99 |
100 | /// Recursive protocol that represents displaying nested list items in ``SherlockView/list(data:id:rowContent:)``.
101 | public protocol SimpleListItem
102 | {
103 | associatedtype Content
104 |
105 | /// Content body of the item.
106 | var content: Content { get }
107 |
108 | /// Search keywords derived from ``content``.
109 | func getKeywords() -> [String]
110 |
111 | /// Copyable key-value pair derived from ``content``.
112 | func getFormCellCopyableKeyValue() -> FormCellCopyableKeyValue?
113 | }
114 |
115 | extension SimpleListItem where Content: CustomStringConvertible
116 | {
117 | // Default implementation.
118 | public func getKeywords() -> [String]
119 | {
120 | [content.description]
121 | }
122 |
123 | public func getFormCellCopyableKeyValue() -> FormCellCopyableKeyValue?
124 | {
125 | .init(key: content.description)
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/UserDefaultsItemView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Single key-value pair viewer for `UserDefaults`.
4 | struct UserDefaultsItemView: View, SherlockView
5 | {
6 | // TODO: Add custom filtering to search per line.
7 | @State var searchText: String = ""
8 |
9 | @State private var canEditAsString: Bool = false
10 |
11 | // TODO: Replace with `\.dismiss` for iOS 15.
12 | @Environment(\.presentationMode) private var presentationMode
13 |
14 | private let key: String
15 | private let value: Any
16 | private var editableString: AppStorage
17 |
18 | init(key: String, value: Any, userDefaults: UserDefaults = .standard)
19 | {
20 | self.key = key
21 | self.value = value
22 |
23 | if let value = value as? String {
24 | self.editableString = AppStorage(wrappedValue: value, key, store: userDefaults)
25 | self.canEditAsString = true
26 | }
27 | else {
28 | self.editableString = AppStorage(wrappedValue: "\(value)", key, store: userDefaults)
29 | self.canEditAsString = false
30 | }
31 | }
32 |
33 | var body: some View
34 | {
35 | NavigationView {
36 | SherlockForm(searchText: $searchText) {
37 | _body(canEditAsString: canEditAsString)
38 | }
39 | .formCellCopyable(true)
40 | .navigationTitle(key)
41 | .navigationBarTitleDisplayMode(.inline)
42 | .toolbar {
43 | ToolbarItemGroup(placement: .navigationBarTrailing) {
44 | Button(action: { presentationMode.wrappedValue.dismiss() }, label: {
45 | Image(systemName: "xmark")
46 | })
47 | }
48 |
49 | ToolbarItemGroup(placement: .bottomBar) {
50 | if !canEditAsString {
51 | Spacer()
52 | Button(action: { canEditAsString = true }, label: {
53 | Image(systemName: "exclamationmark.triangle")
54 | Text("Edit as String (Unsafe)")
55 | })
56 | Spacer()
57 | }
58 | }
59 | }
60 | }
61 | }
62 |
63 | @ViewBuilder
64 | private func _body(canEditAsString: Bool) -> some View
65 | {
66 | Section {
67 | textCell(title: "\(key)")
68 | } header: {
69 | Text("Key")
70 | }
71 |
72 | Section {
73 | textCell(title: "\(type(of: value))")
74 | } header: {
75 | Text("Type")
76 | }
77 |
78 | Section {
79 | textEditorCell(value: editableString.projectedValue, modify: { textEditor in
80 | textEditor
81 | .padding(canEditAsString ? 8 : 0)
82 | .disabled(!canEditAsString)
83 | .overlay(
84 | RoundedRectangle(cornerRadius: 8)
85 | .stroke(Color.gray.opacity(canEditAsString ? 0.25 : 0), lineWidth: 0.5)
86 | )
87 | .onChange(of: canEditAsString, perform: { canEditAsString in
88 | // NOTE:
89 | // Set the `editableString.wrappedValue` to tell `textEditor`
90 | // to update its scroll content.
91 | if canEditAsString {
92 | editableString.wrappedValue = editableString.wrappedValue
93 | }
94 | })
95 | })
96 | } header: {
97 | Text("Value")
98 | } footer: {
99 | if !canEditAsString {
100 | Text("""
101 | Note:
102 | Smart type recognition is not supported yet. To (unsafely) edit value as string, tap bottom button.
103 | """)
104 | .padding(.top, 16)
105 | }
106 | }
107 | }
108 |
109 | typealias KeyValue = UserDefaultsListSectionsView.KeyValue
110 | }
111 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/CasePickerCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | /// Picker cell from `enum Value`.
8 | @ViewBuilder
9 | public func casePickerCell(
10 | icon: Image? = nil,
11 | title: String,
12 | selection: Binding
13 | ) -> some View
14 | where Value: CaseIterable & Hashable
15 | {
16 | CasePickerCell(icon: icon, title: title, selection: selection, canShowCell: canShowCell)
17 | }
18 |
19 | /// Picker cell from `enum Value` that is `RawRepresentable`.
20 | @ViewBuilder
21 | public func casePickerCell(
22 | icon: Image? = nil,
23 | title: String,
24 | selection: Binding
25 | ) -> some View
26 | where Value: CaseIterable & Hashable & RawRepresentable
27 | {
28 | RawRepresentableCasePickerCell(icon: icon, title: title, selection: selection, canShowCell: canShowCell)
29 | }
30 | }
31 |
32 | // MARK: - CasePickerCell
33 |
34 | public struct CasePickerCell: View
35 | where Value: Hashable & CaseIterable
36 | {
37 | private let icon: Image?
38 | private let title: String
39 | private let selection: Binding
40 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
41 |
42 | @Environment(\.formCellCopyable)
43 | private var isCopyable: Bool
44 |
45 | @Environment(\.formCellIconWidth)
46 | private var iconWidth: CGFloat?
47 |
48 | internal init(
49 | icon: Image? = nil,
50 | title: String,
51 | selection: Binding,
52 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
53 | )
54 | {
55 | self.icon = icon
56 | self.title = title
57 | self.selection = selection
58 | self.canShowCell = canShowCell
59 | }
60 |
61 | public var body: some View
62 | {
63 | HStackCell(
64 | keywords: [title, "\(selection.wrappedValue)"],
65 | canShowCell: canShowCell,
66 | copyableKeyValue: isCopyable ? .init(key: title, value: "\(selection.wrappedValue)") : nil
67 | ) {
68 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
69 | Picker(selection: selection, label: Text(title)) {
70 | ForEach(Array(Value.allCases), id: \.self) { value in
71 | Text("\(String(describing: value))")
72 | }
73 | }
74 | }
75 | }
76 | }
77 |
78 | // MARK: - RawRepresentableCasePickerCell
79 |
80 | @MainActor
81 | public struct RawRepresentableCasePickerCell: View
82 | where Value: Hashable & CaseIterable & RawRepresentable
83 | {
84 | private let icon: Image?
85 | private let title: String
86 | private let selection: Binding
87 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
88 |
89 | @Environment(\.formCellCopyable)
90 | private var isCopyable: Bool
91 |
92 | @Environment(\.formCellIconWidth)
93 | private var iconWidth: CGFloat?
94 |
95 | internal init(
96 | icon: Image? = nil,
97 | title: String,
98 | selection: Binding,
99 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
100 | )
101 | {
102 | self.icon = icon
103 | self.title = title
104 | self.selection = selection
105 | self.canShowCell = canShowCell
106 | }
107 |
108 | public var body: some View
109 | {
110 | let rawValue = selection.wrappedValue.rawValue
111 |
112 | HStackCell(
113 | keywords: [title, "\(rawValue)"],
114 | canShowCell: canShowCell,
115 | copyableKeyValue: isCopyable ? .init(key: title, value: "\(rawValue)") : nil
116 | ) {
117 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
118 | Picker(selection: selection, label: Text(title)) {
119 | ForEach(Array(Value.allCases), id: \.self) { value in
120 | Text("\(String(describing: value.rawValue))")
121 | }
122 | }
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/DeviceInfo/Network.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Originally from https://github.com/noppefoxwolf/DebugMenu
4 | class Network {
5 | private static func ifaddrs() -> [String] {
6 | var addresses = [String]()
7 |
8 | var ifaddr: UnsafeMutablePointer?
9 | guard getifaddrs(&ifaddr) == 0 else { return [] }
10 | guard let firstAddr = ifaddr else { return [] }
11 |
12 | for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) {
13 | let flags = Int32(ptr.pointee.ifa_flags)
14 | var addr = ptr.pointee.ifa_addr.pointee
15 | if (flags & (IFF_UP | IFF_RUNNING | IFF_LOOPBACK)) == (IFF_UP | IFF_RUNNING) {
16 | if addr.sa_family == UInt8(AF_INET) || addr.sa_family == UInt8(AF_INET6) {
17 | var hostname: [CChar] = Array.init(repeating: 0, count: Int(NI_MAXHOST))
18 | if getnameinfo(
19 | &addr,
20 | socklen_t(addr.sa_len),
21 | &hostname,
22 | socklen_t(hostname.count),
23 | nil,
24 | socklen_t(0),
25 | NI_NUMERICHOST
26 | ) == 0 {
27 | let address = String(cString: hostname)
28 | addresses.append(address)
29 | }
30 | }
31 | }
32 | }
33 | freeifaddrs(ifaddr)
34 | return addresses
35 | }
36 |
37 | static func usage() -> NetworkUsage? {
38 | var ifaddr: UnsafeMutablePointer?
39 | guard getifaddrs(&ifaddr) == 0 else { return nil }
40 | guard let firstAddr = ifaddr else { return nil }
41 |
42 | var networkData: UnsafeMutablePointer!
43 |
44 | var wifiDataSent: UInt64 = 0
45 | var wifiDataReceived: UInt64 = 0
46 | var wwanDataSent: UInt64 = 0
47 | var wwanDataReceived: UInt64 = 0
48 |
49 | for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) {
50 | let name = String(cString: ptr.pointee.ifa_name)
51 | let addr = ptr.pointee.ifa_addr.pointee
52 |
53 | guard addr.sa_family == UInt8(AF_LINK) else {
54 | continue
55 | }
56 |
57 | if name.hasPrefix("en") {
58 | networkData = unsafeBitCast(
59 | ptr.pointee.ifa_data,
60 | to: UnsafeMutablePointer.self
61 | )
62 | wifiDataSent += UInt64(networkData.pointee.ifi_obytes)
63 | wifiDataReceived += UInt64(networkData.pointee.ifi_ibytes)
64 | }
65 |
66 | if name.hasPrefix("pdp_ip") {
67 | networkData = unsafeBitCast(
68 | ptr.pointee.ifa_data,
69 | to: UnsafeMutablePointer.self
70 | )
71 | wwanDataSent += UInt64(networkData.pointee.ifi_obytes)
72 | wwanDataReceived += UInt64(networkData.pointee.ifi_ibytes)
73 | }
74 | }
75 | freeifaddrs(ifaddr)
76 |
77 | return .init(
78 | wifiDataSent: wifiDataSent,
79 | wifiDataReceived: wifiDataReceived,
80 | wwanDataSent: wwanDataSent,
81 | wwanDataReceived: wwanDataReceived
82 | )
83 | }
84 | }
85 |
86 | public struct NetworkUsage {
87 | public let wifiDataSent: UInt64
88 | public let wifiDataReceived: UInt64
89 | public let wwanDataSent: UInt64
90 | public let wwanDataReceived: UInt64
91 |
92 | public var sent: UInt64 { wifiDataSent + wwanDataSent }
93 | public var received: UInt64 { wifiDataReceived + wwanDataReceived }
94 |
95 | var prettyPrinted: String {
96 |
97 | func toString(_ bytes: UInt64) -> String {
98 | let formatter = ByteCountFormatter()
99 | formatter.countStyle = .binary
100 | formatter.allowsNonnumericFormatting = false
101 | return formatter.string(fromByteCount: Int64(bytes))
102 | }
103 |
104 | return """
105 | Wifi sent: \(toString(wifiDataSent))
106 | Wifi recv: \(toString(wifiDataReceived))
107 | WWAN sent: \(toString(wwanDataSent))
108 | WWAN recv: \(toString(wwanDataReceived))
109 | """
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/AppInfo/Directory.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Apple-provided fixed directory path wrapper.
4 | ///
5 | /// - SeeAlso : [File System Basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html)
6 | public struct AppleDirectory
7 | {
8 | public let path: String
9 |
10 | internal init(_ directoryPath: String)
11 | {
12 | self.path = directoryPath
13 | }
14 |
15 | public init(_ directory: FileManager.SearchPathDirectory)
16 | {
17 | let expandTilde = true
18 | let directoryPath = NSSearchPathForDirectoriesInDomains(directory, .userDomainMask, expandTilde).first!
19 | self.init(directoryPath)
20 | }
21 | }
22 |
23 | // MARK: - Apple-provided fixed directory paths
24 |
25 | extension AppleDirectory
26 | {
27 | /// `$HOME`.
28 | ///
29 | /// Returns the path to either the user’s or application’s home directory, depending on the platform.
30 | /// In iOS, the home directory is the application’s sandbox directory.
31 | /// In macOS, it’s the application’s sandbox directory, or the current user’s home directory if the application isn’t in a sandbox.
32 | public static let home = AppleDirectory(NSHomeDirectory())
33 |
34 | /// `$HOME/Documents/`.
35 | ///
36 | /// Use this directory to store user-generated content.
37 | /// The contents of this directory can be made available to the user through file sharing;
38 | /// therefore, this directory should only contain files that you may wish to expose to the user.
39 | /// The contents of this directory are backed up by iTunes and iCloud.
40 | public static let document = AppleDirectory(.documentDirectory)
41 |
42 | /// `$HOME/Library/Application Support/`.
43 | ///
44 | /// Use this directory to store all app data files except those associated with the user’s documents.
45 | /// For example, you might use this directory to store app-created data files, configuration files, templates,
46 | /// or other fixed or modifiable resources that are managed by the app.
47 | /// An app might use this directory to store a modifiable copy of resources contained initially in the app’s bundle.
48 | /// A game might use this directory to store new levels purchased by the user and downloaded from a server.
49 | ///
50 | /// All content in this directory should be placed in a custom subdirectory whose name is that of your app’s bundle identifier or your company.
51 | ///
52 | /// In iOS, the contents of this directory are backed up by iTunes and iCloud.
53 | public static let applicationSupport = AppleDirectory(.applicationSupportDirectory)
54 |
55 | /// `$HOME/Library/`.
56 | ///
57 | /// This is the top-level directory for any files that are not user data files.
58 | /// You typically put files in one of several standard subdirectories.
59 | /// iOS apps commonly use the Application Support and Caches subdirectories; however, you can create custom subdirectories.
60 | ///
61 | /// Use the Library subdirectories for any files you don’t want exposed to the user.
62 | /// Your app should not use these directories for user data files.
63 | /// The contents of the Library directory (with the exception of the Caches subdirectory) are backed up by iTunes and iCloud.
64 | public static let library = AppleDirectory(.libraryDirectory)
65 |
66 | /// `$HOME/Library/Caches/`.
67 | ///
68 | /// Use the Library subdirectories for any files you don’t want exposed to the user.
69 | /// Your app should not use these directories for user data files.
70 | /// The contents of the Caches directory are NOT backed up by iTunes and iCloud.
71 | ///
72 | /// In iOS 5.0 and later, the system may delete the Caches directory on rare occasions when the system is very low on disk space.
73 | /// This will never occur while an app is running.
74 | /// However, be aware that restoring from backup is not necessarily the only condition under which the Caches directory can be erased.
75 | public static let caches = AppleDirectory(.cachesDirectory)
76 |
77 | /// `tmp/`.
78 | ///
79 | /// Use this directory to write temporary files that do not need to persist between launches of your app.
80 | /// Your app should remove files from this directory when they are no longer needed;
81 | /// however, the system may purge this directory when your app is not running.
82 | /// The contents of this directory are not backed up by iTunes or iCloud.
83 | public static let tmp = AppleDirectory(NSTemporaryDirectory())
84 |
85 | // FIXME: Add more.
86 | }
87 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/ButtonDialogCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | @available(iOS 15.0, *)
6 | extension SherlockView
7 | {
8 | /// `buttonCell` with `confirmationDialog`.
9 | @ViewBuilder
10 | public func buttonDialogCell(
11 | icon: Image? = nil,
12 | title: String,
13 | dialogTitle: String? = nil,
14 | dialogButtons: [ButtonDialogCell.DialogButton]
15 | ) -> ButtonDialogCell
16 | {
17 | ButtonDialogCell(
18 | icon: icon,
19 | title: title,
20 | dialogTitle: dialogTitle,
21 | dialogButtons: dialogButtons,
22 | canShowCell: canShowCell
23 | )
24 | }
25 | }
26 |
27 | // MARK: - ButtonDialogCell
28 |
29 | @MainActor
30 | @available(iOS 15.0, *)
31 | public struct ButtonDialogCell: View
32 | {
33 | private let icon: Image?
34 | private let title: String
35 | private let dialogTitle: String?
36 | private let dialogButtons: [DialogButton]
37 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
38 |
39 | @State private var confirmation: Void?
40 | @State private var isLoading: Bool = false
41 | @State private var currentTask: Task?
42 |
43 | @Environment(\.formCellCopyable)
44 | private var isCopyable: Bool
45 |
46 | @Environment(\.formCellIconWidth)
47 | private var iconWidth: CGFloat?
48 |
49 | internal init(
50 | icon: Image? = nil,
51 | title: String,
52 | dialogTitle: String? = nil,
53 | dialogButtons: [DialogButton],
54 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
55 | )
56 | {
57 | self.icon = icon
58 | self.title = title
59 | self.dialogTitle = dialogTitle
60 | self.dialogButtons = dialogButtons
61 | self.canShowCell = canShowCell
62 | }
63 |
64 | public var body: some View
65 | {
66 | let hasDialogTitle = !(dialogTitle ?? "").isEmpty
67 |
68 | HStackCell(
69 | keywords: [title],
70 | canShowCell: canShowCell,
71 | copyableKeyValue: isCopyable ? .init(key: title) : nil
72 | ) {
73 | Group {
74 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
75 | Button(title, action: {
76 | currentTask?.cancel()
77 | isLoading = false
78 | confirmation = ()
79 | })
80 |
81 | if isLoading {
82 | Spacer()
83 | ProgressView()
84 | .onTapGesture {
85 | currentTask?.cancel()
86 | isLoading = false
87 | }
88 | }
89 | }
90 | .confirmationDialog(
91 | title: { _ in Text(dialogTitle ?? title) },
92 | titleVisibility: hasDialogTitle ? .visible : .automatic,
93 | unwrapping: $confirmation,
94 | actions: { _ in
95 | ForEach(0 ..< dialogButtons.count, id: \.self) { i in
96 | let dialogButton = dialogButtons[i]
97 | let action = dialogButton.action
98 | Button.init(dialogButton.title, role: dialogButton.role, action: {
99 | currentTask?.cancel()
100 | currentTask = Task {
101 | confirmation = nil
102 |
103 | if dialogButton.role == .cancel {
104 | try await action()
105 | } else {
106 | isLoading = true
107 | try await action()
108 | isLoading = false
109 | }
110 | }
111 | })
112 | }
113 | },
114 | message: { _ in }
115 | )
116 | }
117 | }
118 | }
119 |
120 | @available(iOS 15.0, *)
121 | extension ButtonDialogCell
122 | {
123 | public struct DialogButton: Sendable
124 | {
125 | let title: String
126 | let role: ButtonRole?
127 | let action: @MainActor @Sendable () async throws -> Void
128 |
129 | public init(
130 | title: String,
131 | role: ButtonRole? = nil,
132 | action: @MainActor @Sendable @escaping () async throws -> Void
133 | )
134 | {
135 | self.title = title
136 | self.role = role
137 | self.action = action
138 | }
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/List/NestedList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | /// Creates a hierarchical list that identifies its rows based on a key path to the identifier of the underlying data.
8 | ///
9 | /// - See: https://developer.apple.com/documentation/swiftui/list/init(_:id:children:rowcontent:)-93wbq
10 | @ViewBuilder
11 | public func nestedList(
12 | data: Data,
13 | id: KeyPath,
14 | @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent
15 | ) -> some View
16 | where
17 | Data: MutableCollection & RangeReplaceableCollection,
18 | Data.Element: NestedListItem,
19 | ID: Hashable,
20 | RowContent: View
21 | {
22 | NestedList(
23 | data: data,
24 | id: id,
25 | canShowCell: canShowCell,
26 | rowContent: rowContent
27 | )
28 | }
29 |
30 | /// Creates a hierarchical list.
31 | @ViewBuilder
32 | public func nestedList(
33 | data: Data,
34 | @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent
35 | ) -> some View
36 | where
37 | Data: MutableCollection & RangeReplaceableCollection,
38 | Data.Element: NestedListItem & Identifiable,
39 | RowContent: View
40 | {
41 | nestedList(
42 | data: data,
43 | id: \.id,
44 | rowContent: rowContent
45 | )
46 | }
47 | }
48 |
49 | // MARK: - NestedList
50 |
51 | @MainActor
52 | private struct NestedList: View
53 | where
54 | Data: MutableCollection & RangeReplaceableCollection,
55 | Data.Element: NestedListItem,
56 | ID: Hashable,
57 | RowContent: View
58 | {
59 | private let data: Data
60 | private let id: KeyPath
61 |
62 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
63 |
64 | private let rowContent: (Data.Element) -> RowContent
65 |
66 | @Environment(\.formCellCopyable)
67 | private var isCopyable: Bool
68 |
69 | internal init(
70 | data: Data,
71 | id: KeyPath,
72 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool,
73 | @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent
74 | )
75 | {
76 | self.data = data
77 | self.id = id
78 | self.canShowCell = canShowCell
79 | self.rowContent = rowContent
80 | }
81 |
82 | public var body: some View
83 | {
84 | List(
85 | data.compactMap { $0.filter(canShowCell: canShowCell) },
86 | id: id,
87 | children: \.children,
88 | rowContent: { item in
89 | if isCopyable, let copyableKeyValue = item.getFormCellCopyableKeyValue() {
90 | rowContent(item)
91 | .modifier(CopyableViewModifier(key: copyableKeyValue.key, value: copyableKeyValue.value))
92 | }
93 | else {
94 | rowContent(item)
95 | }
96 | }
97 | )
98 | }
99 | }
100 |
101 | // MARK: - NestedListItem
102 |
103 | /// Recursive protocol that represents displaying nested list items in ``SherlockView/nestedList(data:id:rowContent:)``.
104 | public protocol NestedListItem
105 | {
106 | associatedtype Content
107 |
108 | /// Content body of the item.
109 | var content: Content { get }
110 |
111 | var children: [Self]? { get }
112 |
113 | init(content: Content, children: [Self]?)
114 |
115 | /// Search keywords derived from ``content``.
116 | func getKeywords() -> [String]
117 |
118 | /// Copyable key-value pair derived from ``content``.
119 | func getFormCellCopyableKeyValue() -> FormCellCopyableKeyValue?
120 | }
121 |
122 | extension NestedListItem where Content: CustomStringConvertible
123 | {
124 | // Default implementation.
125 | public func getKeywords() -> [String]
126 | {
127 | [content.description]
128 | }
129 |
130 | public func getFormCellCopyableKeyValue() -> FormCellCopyableKeyValue?
131 | {
132 | .init(key: content.description)
133 | }
134 | }
135 |
136 | extension NestedListItem
137 | {
138 | /// Recursive filtering.
139 | /// Rule: If child item matches, then parent item should also be visible.
140 | @MainActor
141 | fileprivate func filter(
142 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool
143 | ) -> Self?
144 | {
145 | if canShowCell(getKeywords()) {
146 | return self
147 | }
148 |
149 | if let children = children, !children.isEmpty {
150 | let filteredChildren = children.compactMap { $0.filter(canShowCell: canShowCell) }
151 |
152 | if !filteredChildren.isEmpty {
153 | return Self.init(content: content, children: filteredChildren)
154 | }
155 | }
156 |
157 | return nil
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/Sources/SherlockDebugForms/DeviceInfo/Device.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | /// Originally from https://github.com/noppefoxwolf/DebugMenu
4 | @MainActor
5 | public class Device {
6 | public static let current: Device = .init()
7 |
8 | public var localizedModel: String {
9 | UIDevice.current.localizedModel
10 | }
11 |
12 | public var model: String {
13 | UIDevice.current.model
14 | }
15 |
16 | public var name: String {
17 | UIDevice.current.name
18 | }
19 |
20 | public var systemName: String {
21 | UIDevice.current.systemName
22 | }
23 |
24 | public var systemVersion: String {
25 | UIDevice.current.systemVersion
26 | }
27 |
28 | public var localizedBatteryLevel: String {
29 | "\(batteryLevel * 100.00) %"
30 | }
31 |
32 | public var batteryLevel: Float {
33 | UIDevice.current.batteryLevel
34 | }
35 |
36 | public var batteryState: UIDevice.BatteryState {
37 | UIDevice.current.batteryState
38 | }
39 |
40 | public var localizedBatteryState: String {
41 | switch batteryState {
42 | case .unknown: return "unknown"
43 | case .unplugged: return "unplugged"
44 | case .charging: return "charging"
45 | case .full: return "full"
46 | @unknown default: return "default"
47 | }
48 | }
49 |
50 | public var isJailbreaked: Bool {
51 | FileManager.default.fileExists(atPath: "/private/var/lib/apt")
52 | }
53 |
54 | public var thermalState: ProcessInfo.ThermalState {
55 | ProcessInfo.processInfo.thermalState
56 | }
57 |
58 | public var localizedThermalState: String {
59 | switch thermalState {
60 | case .nominal: return "nominal"
61 | case .fair: return "fair"
62 | case .serious: return "serious"
63 | case .critical: return "critical"
64 | @unknown default: return "default"
65 | }
66 | }
67 |
68 | public var processorCount: Int {
69 | ProcessInfo.processInfo.processorCount
70 | }
71 |
72 | public var activeProcessorCount: Int {
73 | ProcessInfo.processInfo.activeProcessorCount
74 | }
75 |
76 | public var processor: String {
77 | "\(activeProcessorCount) / \(processorCount)"
78 | }
79 |
80 | public var isLowPowerModeEnabled: Bool {
81 | ProcessInfo.processInfo.isLowPowerModeEnabled
82 | }
83 |
84 | public var physicalMemory: UInt64 {
85 | ProcessInfo.processInfo.physicalMemory
86 | }
87 |
88 | public var localizedPhysicalMemory: String {
89 | let formatter = ByteCountFormatter()
90 | formatter.countStyle = .memory
91 | formatter.allowsNonnumericFormatting = false
92 | return formatter.string(fromByteCount: Int64(physicalMemory))
93 | }
94 |
95 | // without sleep time
96 | public var systemUptime: TimeInterval {
97 | ProcessInfo.processInfo.systemUptime
98 | }
99 |
100 | // include sleep time
101 | public func uptime() -> time_t {
102 | System.uptime()
103 | }
104 |
105 | public var localizedSystemUptime: String {
106 | let formatter = DateComponentsFormatter()
107 | formatter.unitsStyle = .brief
108 | formatter.allowedUnits = [.day, .hour, .minute, .second]
109 | return formatter.string(from: systemUptime) ?? "-"
110 | }
111 |
112 | public var localizedUptime: String {
113 | let formatter = DateComponentsFormatter()
114 | formatter.unitsStyle = .brief
115 | formatter.allowedUnits = [.day, .hour, .minute, .second]
116 | return formatter.string(from: TimeInterval(uptime())) ?? "-"
117 | }
118 |
119 | public var diskTotalSpace: Int64 {
120 | if let attributes = try? FileManager.default.attributesOfFileSystem(
121 | forPath: NSHomeDirectory()
122 | ) {
123 | return attributes[.systemSize] as! Int64
124 | } else {
125 | return 0
126 | }
127 | }
128 |
129 | public var diskFreeSpace: Int64 {
130 | if let attributes = try? FileManager.default.attributesOfFileSystem(
131 | forPath: NSHomeDirectory()
132 | ) {
133 | return attributes[.systemFreeSize] as! Int64
134 | } else {
135 | return 0
136 | }
137 | }
138 |
139 | public var diskUsage: Int64 {
140 | diskTotalSpace - diskFreeSpace
141 | }
142 |
143 | public var localizedDiskUsage: String {
144 | let formatter = ByteCountFormatter()
145 | formatter.countStyle = .file
146 | formatter.allowsNonnumericFormatting = false
147 | return "\(formatter.string(fromByteCount: diskUsage)) / \(formatter.string(fromByteCount: diskTotalSpace))"
148 | }
149 |
150 | public var localizedMemoryUsage: String {
151 | let formatter = ByteCountFormatter()
152 | formatter.countStyle = .memory
153 | formatter.allowsNonnumericFormatting = false
154 | return formatter.string(fromByteCount: Int64(memoryUsage()))
155 | }
156 |
157 | public func memoryUsage() -> UInt64 {
158 | Memory.usage()
159 | }
160 |
161 | public var localizedCPUUsage: String {
162 | String(format: "%.1f%%", cpuUsage() * 100.0)
163 | }
164 |
165 | public func cpuUsage() -> Double {
166 | CPU.usage()
167 | }
168 |
169 | public func networkUsage() -> NetworkUsage? {
170 | Network.usage()
171 | }
172 |
173 | public var localizedGPUMemory: String {
174 | let formatter = ByteCountFormatter()
175 | formatter.countStyle = .memory
176 | formatter.allowsNonnumericFormatting = false
177 | return formatter.string(fromByteCount: Int64(GPU.current.currentAllocatedSize))
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/ArrayPickerCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | /// Picker cell with `selection: Binding` from `values` array.
8 | @ViewBuilder
9 | public func arrayPickerCell(
10 | icon: Image? = nil,
11 | title: String,
12 | selection: Binding,
13 | values: [Value]
14 | ) -> some View
15 | where Value: Hashable
16 | {
17 | ArrayPickerCell(icon: icon, title: title, selection: selection, values: values, canShowCell: canShowCell)
18 | }
19 |
20 | /// Async-picker cell with `selection: Binding` from `action`-fetched values.
21 | ///
22 | /// 1. Before `action`: Shows `title` and `accessory`
23 | /// 2. After `action`: Shows ``ArrayPickerCell`` with values being fetched by `action`.
24 | ///
25 | /// # Known issue
26 | /// When this cell appears at middle of the form after scroll, and `accessory` contains `ProgressView`,
27 | /// it may not animate correctly, possibly due to SwiftUI bug.
28 | @ViewBuilder
29 | public func arrayPickerCell(
30 | icon: Image? = nil,
31 | title: String,
32 | selection: Binding,
33 | @ViewBuilder accessory: @escaping () -> Accessory,
34 | action: @Sendable @escaping () async throws -> [Value],
35 | valueType: Value.Type = Value.self
36 | ) -> some View
37 | where Value: Hashable, Accessory: View
38 | {
39 | AsyncArrayPickerCell(
40 | icon: icon,
41 | title: title,
42 | selection: selection,
43 | accessory: accessory,
44 | action: action,
45 | canShowCell: canShowCell
46 | )
47 | }
48 | }
49 |
50 | // MARK: - ArrayPickerCell
51 |
52 | @MainActor
53 | public struct ArrayPickerCell: View
54 | where Value: Hashable
55 | {
56 | private let icon: Image?
57 | private let title: String
58 | private let selection: Binding
59 | private let values: [Value]
60 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
61 |
62 | @Environment(\.formCellCopyable)
63 | private var isCopyable: Bool
64 |
65 | @Environment(\.formCellIconWidth)
66 | private var iconWidth: CGFloat?
67 |
68 | internal init(
69 | icon: Image? = nil,
70 | title: String,
71 | selection: Binding,
72 | values: [Value],
73 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
74 | )
75 | {
76 | self.icon = icon
77 | self.title = title
78 | self.selection = selection
79 | self.values = values
80 | self.canShowCell = canShowCell
81 | }
82 |
83 | public var body: some View
84 | {
85 | HStackCell(
86 | keywords: [title],
87 | canShowCell: canShowCell,
88 | copyableKeyValue: isCopyable
89 | ? .init(
90 | key: title,
91 | value: "\(selection.wrappedValue)"
92 | )
93 | : nil
94 | ) {
95 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth)
96 |
97 | Picker(selection: selection) {
98 | ForEach(0 ..< values.count, id: \.self) { i in
99 | let value = values[i]
100 | Text("\(String(describing: value))")
101 | .tag(value)
102 | }
103 | } label: {
104 | Text(title)
105 | }
106 | .overlay(
107 | Group {
108 | Spacer()
109 |
110 | ProgressView()
111 | .padding(.trailing, 16)
112 | .opacity(values.isEmpty ? 1 : 0)
113 | }
114 | )
115 | }
116 | }
117 | }
118 |
119 | // MARK: - AsyncArrayPickerCell
120 |
121 | @MainActor
122 | public struct AsyncArrayPickerCell: View
123 | where Value: Hashable, Accessory: View
124 | {
125 | private let icon: Image?
126 | private let title: String
127 | private let selection: Binding
128 | private let accessory: () -> AnyView
129 | private let action: () async throws -> [Value]
130 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool
131 |
132 | @State private var values: [Value] = []
133 |
134 | @Environment(\.formCellCopyable)
135 | private var isCopyable: Bool
136 |
137 | @Environment(\.formCellIconWidth)
138 | private var iconWidth: CGFloat?
139 |
140 | internal init(
141 | icon: Image? = nil,
142 | title: String,
143 | selection: Binding,
144 | @ViewBuilder accessory: @escaping () -> Accessory,
145 | action: @Sendable @escaping () async throws -> [Value],
146 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true }
147 | )
148 | {
149 | self.icon = icon
150 | self.title = title
151 | self.selection = selection
152 | self.accessory = { AnyView(accessory()) }
153 | self.action = action
154 | self.canShowCell = canShowCell
155 | }
156 |
157 | public var body: some View
158 | {
159 | if values.isEmpty {
160 | TextCell(icon: icon, title: title, value: nil, accessory: accessory, canShowCell: canShowCell)
161 | .onAppear {
162 | guard values.isEmpty else { return }
163 |
164 | Task {
165 | values = try await action()
166 | }
167 | }
168 | }
169 | else {
170 | ArrayPickerCell(icon: icon, title: title, selection: selection, values: values, canShowCell: canShowCell)
171 | }
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/Sources/SherlockForms/FormCells/SliderCell.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Constructors
4 |
5 | extension SherlockView
6 | {
7 | @ViewBuilder
8 | public func sliderCell(
9 | icon: Image? = nil,
10 | title: String,
11 | value: Binding,
12 | in bounds: ClosedRange,
13 | step: Value.Stride = 1,
14 | maxFractionDigits: Int? = nil,
15 | valueString: @escaping (_ value: String) -> String = { $0 },
16 | onEditingChanged: @escaping (Bool) -> Void = { _ in }
17 | ) -> SliderCell
18 | where Value: BinaryFloatingPoint, Value.Stride: BinaryFloatingPoint
19 | {
20 | SliderCell(
21 | icon: icon,
22 | title: title,
23 | value: value,
24 | in: bounds,
25 | step: step,
26 | maxFractionDigits: maxFractionDigits,
27 | valueString: valueString,
28 | sliderLabel: { EmptyView() },
29 | minimumValueLabel: { EmptyView() },
30 | maximumValueLabel: { EmptyView() },
31 | onEditingChanged: onEditingChanged,
32 | canShowCell: canShowCell
33 | )
34 | }
35 |
36 | @ViewBuilder
37 | public func sliderCell(
38 | icon: Image? = nil,
39 | title: String,
40 | value: Binding,
41 | in bounds: ClosedRange,
42 | step: Value.Stride = 1,
43 | maxFractionDigits: Int? = nil,
44 | valueString: @escaping (_ value: String) -> String = { $0 },
45 | sliderLabel: @escaping () -> Label,
46 | minimumValueLabel: @escaping () -> ValueLabel,
47 | maximumValueLabel: @escaping () -> ValueLabel,
48 | onEditingChanged: @escaping (Bool) -> Void = { _ in }
49 | ) -> SliderCell