├── 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 50 | where Value: BinaryFloatingPoint, Value.Stride: BinaryFloatingPoint 51 | { 52 | SliderCell( 53 | icon: icon, 54 | title: title, 55 | value: value, 56 | in: bounds, 57 | step: step, 58 | maxFractionDigits: maxFractionDigits, 59 | valueString: valueString, 60 | sliderLabel: sliderLabel, 61 | minimumValueLabel: minimumValueLabel, 62 | maximumValueLabel: maximumValueLabel, 63 | onEditingChanged: onEditingChanged, 64 | canShowCell: canShowCell 65 | ) 66 | } 67 | } 68 | 69 | // MARK: - SliderCell 70 | 71 | @MainActor 72 | public struct SliderCell: View 73 | where Label: View, ValueLabel: View 74 | { 75 | private let icon: Image? 76 | private let title: String 77 | 78 | @Binding private var value: Double 79 | 80 | private let bounds: ClosedRange 81 | private let step: Double 82 | private let maxFractionDigits: Int? 83 | private let valueString: (_ value: String) -> String 84 | private let sliderLabel: () -> Label 85 | private let minimumValueLabel: () -> ValueLabel 86 | private let maximumValueLabel: () -> ValueLabel 87 | private let onEditingChanged: (Bool) -> Void 88 | private let canShowCell: @MainActor (_ keywords: [String]) -> Bool 89 | 90 | @Environment(\.formCellCopyable) 91 | private var isCopyable: Bool 92 | 93 | @Environment(\.formCellIconWidth) 94 | private var iconWidth: CGFloat? 95 | 96 | internal init( 97 | icon: Image? = nil, 98 | title: String, 99 | value: Binding, 100 | in bounds: ClosedRange, 101 | step: Value.Stride = 1, 102 | maxFractionDigits: Int? = nil, 103 | valueString: @escaping (_ value: String) -> String = { $0 }, 104 | sliderLabel: @escaping () -> Label, 105 | minimumValueLabel: @escaping () -> ValueLabel, 106 | maximumValueLabel: @escaping () -> ValueLabel, 107 | onEditingChanged: @escaping (Bool) -> Void = { _ in }, 108 | canShowCell: @MainActor @escaping (_ keywords: [String]) -> Bool = { _ in true } 109 | ) 110 | where Value: BinaryFloatingPoint, Value.Stride: BinaryFloatingPoint 111 | { 112 | self.icon = icon 113 | self.title = title 114 | self._value = Binding( 115 | get: { Double(value.wrappedValue) }, 116 | set: { value.wrappedValue = Value($0) } 117 | ) 118 | self.bounds = .init( 119 | uncheckedBounds: (lower: Double(bounds.lowerBound), upper: Double(bounds.upperBound)) 120 | ) 121 | self.step = Double(step) 122 | self.sliderLabel = sliderLabel 123 | self.maxFractionDigits = maxFractionDigits 124 | self.valueString = valueString 125 | self.minimumValueLabel = minimumValueLabel 126 | self.maximumValueLabel = maximumValueLabel 127 | self.onEditingChanged = onEditingChanged 128 | self.canShowCell = canShowCell 129 | } 130 | 131 | public var body: some View 132 | { 133 | let valueString_ = valueString("\(value.string(maxFractionDigits: maxFractionDigits))") 134 | 135 | VStackCell( 136 | keywords: [title, valueString_], 137 | canShowCell: canShowCell, 138 | copyableKeyValue: isCopyable ? .init(key: title, value: valueString_) : nil 139 | ) { 140 | HStack { 141 | icon.frame(minWidth: iconWidth, maxWidth: iconWidth) 142 | Text(title) 143 | Spacer() 144 | Text(valueString_) 145 | .font(.body.monospacedDigit()) 146 | } 147 | 148 | Slider( 149 | value: $value, 150 | in: bounds, 151 | step: step, 152 | label: sliderLabel, 153 | minimumValueLabel: minimumValueLabel, 154 | maximumValueLabel: maximumValueLabel, 155 | onEditingChanged: onEditingChanged 156 | ) 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🕵️‍♂️ SherlockForms 2 | 3 | > What one man can invent Settings UI, another can discover its field. 4 | > 5 | > -- Sherlock Forms 6 | 7 | An elegant SwiftUI Form builder to create a searchable Settings and DebugMenu screens for iOS. 8 | 9 | (Supports from iOS 14, except `.searchable` works from iOS 15) 10 | 11 | ## Overview 12 | 13 | | Normal | Searching | Context Menu | 14 | |---|---|---| 15 | | | | | 16 | 17 | | UserDefaults | App Info | Device Info | 18 | |---|---|---| 19 | | | | | 20 | 21 | This repository consists of 3 modules: 22 | 23 | 1. `SherlockForms`: SwiftUI Form builder to enhance cell findability using iOS 15 `.searchable`. 24 | - [x] Various form cells to automagically interact with `.searchable`, including Text, Button, Toggle, Picker, NavigationLink, etc. 25 | - [x] "Copy text" from context menu by long-press 26 | 2. `SherlockDebugForms`: Useful app/device info-views and helper methods, specifically for debugging purpose. 27 | - [x] App Info view 28 | - [x] Device Info view 29 | - [x] UserDefaults Editor 30 | - [ ] TODO: File Browser 31 | - [ ] TODO: Console Logger 32 | 3. `SherlockHUD`: Standalone, simple-to-use Notification View (Toast) UI used in `SherlockForms` 33 | 34 | ## Examples 35 | 36 | ### `SherlockForms` & `SherlockDebugForms` 37 | 38 | From [SherlockForms-Gallery app](Examples/SherlockForms-Gallery.swiftpm): 39 | 40 | ```swift 41 | import SwiftUI 42 | import SherlockDebugForms 43 | 44 | /// NOTE: Each view that owns `SherlockForm` needs to conform to `SherlockView` protocol. 45 | @MainActor 46 | struct RootView: View, SherlockView 47 | { 48 | /// NOTE: 49 | /// `searchText` is required for `SherlockView` protocol. 50 | /// This is the only requirement to define as `@State`, and pass it to `SherlockForm`. 51 | @State public var searchText: String = "" 52 | 53 | @AppStorage("username") 54 | private var username: String = "John Appleseed" 55 | 56 | @AppStorage("language") 57 | private var languageSelection: Int = 0 58 | 59 | @AppStorage("status") 60 | private var status = Constant.Status.online 61 | 62 | ... // Many more @AppStorage properties... 63 | 64 | var body: some View 65 | { 66 | // NOTE: 67 | // `SherlockForm` and `xxxCell` are where all the search magic is happening! 68 | // Just treat `SherlockForm` as a normal `Form`, and use `Section` and plain SwiftUI views accordingly. 69 | SherlockForm(searchText: $searchText) { 70 | 71 | // Simple form cells. 72 | Section { 73 | textCell(title: "User", value: username) 74 | arrayPickerCell(title: "Language", selection: $languageSelection, values: Constant.languages) 75 | casePickerCell(title: "Status", selection: $status) 76 | toggleCell(title: "Low Power Mode", isOn: $isLowPowerOn) 77 | 78 | sliderCell( 79 | title: "Speed", 80 | value: $speed, 81 | in: 0.5 ... 2.0, 82 | step: 0.1, 83 | maxFractionDigits: 1, 84 | valueString: { "x\($0)" }, 85 | sliderLabel: { EmptyView() }, 86 | minimumValueLabel: { Image(systemName: "tortoise") }, 87 | maximumValueLabel: { Image(systemName: "hare") }, 88 | onEditingChanged: { print("onEditingChanged", $0) } 89 | ) 90 | 91 | stepperCell( 92 | title: "Font Size", 93 | value: $fontSize, 94 | in: 8 ... 24, 95 | step: 1, 96 | maxFractionDigits: 0, 97 | valueString: { "\($0) pt" } 98 | ) 99 | } 100 | 101 | // Navigation Link Cell (`navigationLinkCell`) 102 | Section { 103 | navigationLinkCell( 104 | title: "UserDefaults", 105 | destination: { UserDefaultsListView() } 106 | ) 107 | navigationLinkCell( 108 | title: "App Info", 109 | destination: { AppInfoView() } 110 | ) 111 | navigationLinkCell( 112 | title: "Device Info", 113 | destination: { DeviceInfoView() } 114 | ) 115 | navigationLinkCell(title: "Custom Page", destination: { 116 | CustomView() 117 | }) 118 | } 119 | 120 | // Buttons 121 | Section { 122 | buttonCell( 123 | title: "Reset UserDefaults", 124 | action: { 125 | Helper.deleteUserDefaults() 126 | showHUD(.init(message: "Finished resetting UserDefaults")) 127 | } 128 | ) 129 | 130 | buttonDialogCell( 131 | title: "Delete All Contents", 132 | dialogTitle: nil, 133 | dialogButtons: [ 134 | .init(title: "Delete All Contents", role: .destructive) { 135 | try await deleteAllContents() 136 | showHUD(.init(message: "Finished deleting all contents")) 137 | }, 138 | .init(title: "Cancel", role: .cancel) { 139 | print("Cancelled") 140 | } 141 | ] 142 | ) 143 | } 144 | } 145 | .navigationTitle("Settings") 146 | // NOTE: 147 | // Use `formCopyable` here to allow ALL `xxxCell`s to be copyable. 148 | .formCopyable(true) 149 | } 150 | } 151 | ``` 152 | 153 | To get started: 154 | 155 | 1. Conform your Settings view to `protocol SherlockView` 156 | 2. Add `@State var searchText: String` to your view 157 | 3. Inside view's `body`, use `SherlockForm` (just like normal `Form`), and use various built-in form components: 158 | - Basic built-in cells 159 | - `textCell` 160 | - `textFieldCell` 161 | - `textEditorCell` 162 | - `buttonCell` 163 | - `buttonDialogCell` (iOS 15) 164 | - `navigationLinkCell` 165 | - `toggleCell` 166 | - `arrayPickerCell` 167 | - `casePickerCell` 168 | - `datePickerCell` 169 | - `sliderCell` 170 | - `stepperCell` 171 | - List 172 | - `simpleList` 173 | - `nestedList` 174 | - More customizable cells (part of `ContainerCell`) 175 | - `hstackCell` 176 | - `vstackCell` 177 | 4. (Optional) Attach `.formCellCopyable(true)` to each cell or entire form. 178 | 5. (Optional) Attach `.enableSherlockHUD(true)` to topmost view hierarchy to enable HUD 179 | 180 | To customize cell's internal content view rather than cell itself, 181 | use `.formCellContentModifier` which may solve some troubles (e.g. context menu) when customizing cells. 182 | 183 | ### `SherlockHUD` 184 | 185 | ```swift 186 | import SwiftUI 187 | import SherlockHUD 188 | 189 | @main 190 | struct MyApp: App 191 | { 192 | var body: some Scene 193 | { 194 | WindowGroup { 195 | NavigationView { 196 | RootView() 197 | } 198 | .enableSherlockHUD(true) // Set at the topmost view! 199 | } 200 | } 201 | } 202 | 203 | @MainActor 204 | struct RootView: View 205 | { 206 | /// Attaching `.enableSherlockHUD(true)` to topmost view will allow using `showHUD`. 207 | @Environment(\.showHUD) 208 | private var showHUD: (HUDMessage) -> Void 209 | 210 | var body: some View 211 | { 212 | VStack(spacing: 16) { 213 | Button("Tap") { 214 | showHUD(HUDMessage(message: "Hello SherlockForms!", duration: 2, alignment: .top)) 215 | // alignment = top / center / bottom (default) 216 | // Can also attach custom view e.g. ProgressView. See also `HUDMessage.loading`. 217 | } 218 | } 219 | .font(.largeTitle) 220 | } 221 | } 222 | ``` 223 | 224 | See [SherlockHUD-Demo app](Examples/SherlockHUD-Demo.swiftpm) for more information. 225 | 226 | ## Acknowledgement 227 | 228 | - [DebugMenu](https://github.com/noppefoxwolf/DebugMenu) by [@noppefoxwolf](https://github.com/noppefoxwolf) for various useful code in debugging 229 | - [swiftui-navigation](https://github.com/pointfreeco/swiftui-navigation) by [@pointfreeco](https://github.com/pointfreeco) for making smart state-binding techniques in SwiftUI navigation 230 | - [Custom HUDs in SwiftUI | FIVE STARS](https://www.fivestars.blog/articles/swiftui-hud/) by [Federico Zanetello](https://twitter.com/zntfdr) for easy-to-learn SwiftUI HUD development 231 | - [@inamiy](https://github.com/inamiy)'s Wife for dedicated support during this OSS development 232 | 233 | ## License 234 | 235 | [MIT](LICENSE) 236 | -------------------------------------------------------------------------------- /Examples/SherlockForms-Gallery-iOS14.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 33E6235427CE7A2F005E399E /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E6234F27CE7A2F005E399E /* RootView.swift */; }; 11 | 33E6235527CE7A2F005E399E /* MyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E6235027CE7A2F005E399E /* MyApp.swift */; }; 12 | 33E6235627CE7A2F005E399E /* UserDefaultsKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E6235127CE7A2F005E399E /* UserDefaultsKey.swift */; }; 13 | 33E6235727CE7A2F005E399E /* Constant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E6235227CE7A2F005E399E /* Constant.swift */; }; 14 | 33E6235827CE7A2F005E399E /* CustomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E6235327CE7A2F005E399E /* CustomView.swift */; }; 15 | 33E6235C27CE7A5F005E399E /* SherlockDebugForms in Frameworks */ = {isa = PBXBuildFile; productRef = 33E6235B27CE7A5F005E399E /* SherlockDebugForms */; }; 16 | /* End PBXBuildFile section */ 17 | 18 | /* Begin PBXFileReference section */ 19 | 33E6233E27CE79DC005E399E /* SherlockForms-Gallery-iOS14.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SherlockForms-Gallery-iOS14.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 20 | 33E6234F27CE7A2F005E399E /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RootView.swift; path = "SherlockForms-Gallery.swiftpm/RootView.swift"; sourceTree = SOURCE_ROOT; }; 21 | 33E6235027CE7A2F005E399E /* MyApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MyApp.swift; path = "SherlockForms-Gallery.swiftpm/MyApp.swift"; sourceTree = SOURCE_ROOT; }; 22 | 33E6235127CE7A2F005E399E /* UserDefaultsKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = UserDefaultsKey.swift; path = "SherlockForms-Gallery.swiftpm/UserDefaultsKey.swift"; sourceTree = SOURCE_ROOT; }; 23 | 33E6235227CE7A2F005E399E /* Constant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Constant.swift; path = "SherlockForms-Gallery.swiftpm/Constant.swift"; sourceTree = SOURCE_ROOT; }; 24 | 33E6235327CE7A2F005E399E /* CustomView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CustomView.swift; path = "SherlockForms-Gallery.swiftpm/CustomView.swift"; sourceTree = SOURCE_ROOT; }; 25 | 33E6235927CE7A55005E399E /* SherlockForms */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SherlockForms; path = ..; sourceTree = ""; }; 26 | /* End PBXFileReference section */ 27 | 28 | /* Begin PBXFrameworksBuildPhase section */ 29 | 33E6233B27CE79DC005E399E /* Frameworks */ = { 30 | isa = PBXFrameworksBuildPhase; 31 | buildActionMask = 2147483647; 32 | files = ( 33 | 33E6235C27CE7A5F005E399E /* SherlockDebugForms in Frameworks */, 34 | ); 35 | runOnlyForDeploymentPostprocessing = 0; 36 | }; 37 | /* End PBXFrameworksBuildPhase section */ 38 | 39 | /* Begin PBXGroup section */ 40 | 33E6233527CE79DC005E399E = { 41 | isa = PBXGroup; 42 | children = ( 43 | 33E6235927CE7A55005E399E /* SherlockForms */, 44 | 33E6234027CE79DC005E399E /* SherlockForms-Gallery-iOS14 */, 45 | 33E6233F27CE79DC005E399E /* Products */, 46 | 33E6235A27CE7A5F005E399E /* Frameworks */, 47 | ); 48 | sourceTree = ""; 49 | }; 50 | 33E6233F27CE79DC005E399E /* Products */ = { 51 | isa = PBXGroup; 52 | children = ( 53 | 33E6233E27CE79DC005E399E /* SherlockForms-Gallery-iOS14.app */, 54 | ); 55 | name = Products; 56 | sourceTree = ""; 57 | }; 58 | 33E6234027CE79DC005E399E /* SherlockForms-Gallery-iOS14 */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | 33E6235227CE7A2F005E399E /* Constant.swift */, 62 | 33E6235327CE7A2F005E399E /* CustomView.swift */, 63 | 33E6235027CE7A2F005E399E /* MyApp.swift */, 64 | 33E6234F27CE7A2F005E399E /* RootView.swift */, 65 | 33E6235127CE7A2F005E399E /* UserDefaultsKey.swift */, 66 | ); 67 | path = "SherlockForms-Gallery-iOS14"; 68 | sourceTree = ""; 69 | }; 70 | 33E6235A27CE7A5F005E399E /* Frameworks */ = { 71 | isa = PBXGroup; 72 | children = ( 73 | ); 74 | name = Frameworks; 75 | sourceTree = ""; 76 | }; 77 | /* End PBXGroup section */ 78 | 79 | /* Begin PBXNativeTarget section */ 80 | 33E6233D27CE79DC005E399E /* SherlockForms-Gallery-iOS14 */ = { 81 | isa = PBXNativeTarget; 82 | buildConfigurationList = 33E6234C27CE79DF005E399E /* Build configuration list for PBXNativeTarget "SherlockForms-Gallery-iOS14" */; 83 | buildPhases = ( 84 | 33E6233A27CE79DC005E399E /* Sources */, 85 | 33E6233B27CE79DC005E399E /* Frameworks */, 86 | 33E6233C27CE79DC005E399E /* Resources */, 87 | ); 88 | buildRules = ( 89 | ); 90 | dependencies = ( 91 | ); 92 | name = "SherlockForms-Gallery-iOS14"; 93 | packageProductDependencies = ( 94 | 33E6235B27CE7A5F005E399E /* SherlockDebugForms */, 95 | ); 96 | productName = "SherlockForms-Gallery-iOS14"; 97 | productReference = 33E6233E27CE79DC005E399E /* SherlockForms-Gallery-iOS14.app */; 98 | productType = "com.apple.product-type.application"; 99 | }; 100 | /* End PBXNativeTarget section */ 101 | 102 | /* Begin PBXProject section */ 103 | 33E6233627CE79DC005E399E /* Project object */ = { 104 | isa = PBXProject; 105 | attributes = { 106 | BuildIndependentTargetsInParallel = 1; 107 | LastSwiftUpdateCheck = 1320; 108 | LastUpgradeCheck = 1320; 109 | TargetAttributes = { 110 | 33E6233D27CE79DC005E399E = { 111 | CreatedOnToolsVersion = 13.2.1; 112 | LastSwiftMigration = 1320; 113 | }; 114 | }; 115 | }; 116 | buildConfigurationList = 33E6233927CE79DC005E399E /* Build configuration list for PBXProject "SherlockForms-Gallery-iOS14" */; 117 | compatibilityVersion = "Xcode 13.0"; 118 | developmentRegion = en; 119 | hasScannedForEncodings = 0; 120 | knownRegions = ( 121 | en, 122 | Base, 123 | ); 124 | mainGroup = 33E6233527CE79DC005E399E; 125 | productRefGroup = 33E6233F27CE79DC005E399E /* Products */; 126 | projectDirPath = ""; 127 | projectRoot = ""; 128 | targets = ( 129 | 33E6233D27CE79DC005E399E /* SherlockForms-Gallery-iOS14 */, 130 | ); 131 | }; 132 | /* End PBXProject section */ 133 | 134 | /* Begin PBXResourcesBuildPhase section */ 135 | 33E6233C27CE79DC005E399E /* Resources */ = { 136 | isa = PBXResourcesBuildPhase; 137 | buildActionMask = 2147483647; 138 | files = ( 139 | ); 140 | runOnlyForDeploymentPostprocessing = 0; 141 | }; 142 | /* End PBXResourcesBuildPhase section */ 143 | 144 | /* Begin PBXSourcesBuildPhase section */ 145 | 33E6233A27CE79DC005E399E /* Sources */ = { 146 | isa = PBXSourcesBuildPhase; 147 | buildActionMask = 2147483647; 148 | files = ( 149 | 33E6235827CE7A2F005E399E /* CustomView.swift in Sources */, 150 | 33E6235627CE7A2F005E399E /* UserDefaultsKey.swift in Sources */, 151 | 33E6235427CE7A2F005E399E /* RootView.swift in Sources */, 152 | 33E6235527CE7A2F005E399E /* MyApp.swift in Sources */, 153 | 33E6235727CE7A2F005E399E /* Constant.swift in Sources */, 154 | ); 155 | runOnlyForDeploymentPostprocessing = 0; 156 | }; 157 | /* End PBXSourcesBuildPhase section */ 158 | 159 | /* Begin XCBuildConfiguration section */ 160 | 33E6234A27CE79DF005E399E /* Debug */ = { 161 | isa = XCBuildConfiguration; 162 | buildSettings = { 163 | ALWAYS_SEARCH_USER_PATHS = NO; 164 | CLANG_ANALYZER_NONNULL = YES; 165 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 166 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 167 | CLANG_CXX_LIBRARY = "libc++"; 168 | CLANG_ENABLE_MODULES = YES; 169 | CLANG_ENABLE_OBJC_ARC = YES; 170 | CLANG_ENABLE_OBJC_WEAK = YES; 171 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 172 | CLANG_WARN_BOOL_CONVERSION = YES; 173 | CLANG_WARN_COMMA = YES; 174 | CLANG_WARN_CONSTANT_CONVERSION = YES; 175 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 176 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 177 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 178 | CLANG_WARN_EMPTY_BODY = YES; 179 | CLANG_WARN_ENUM_CONVERSION = YES; 180 | CLANG_WARN_INFINITE_RECURSION = YES; 181 | CLANG_WARN_INT_CONVERSION = YES; 182 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 183 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 184 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 185 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 186 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 187 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 188 | CLANG_WARN_STRICT_PROTOTYPES = YES; 189 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 190 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 191 | CLANG_WARN_UNREACHABLE_CODE = YES; 192 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 193 | COPY_PHASE_STRIP = NO; 194 | DEBUG_INFORMATION_FORMAT = dwarf; 195 | ENABLE_STRICT_OBJC_MSGSEND = YES; 196 | ENABLE_TESTABILITY = YES; 197 | GCC_C_LANGUAGE_STANDARD = gnu11; 198 | GCC_DYNAMIC_NO_PIC = NO; 199 | GCC_NO_COMMON_BLOCKS = YES; 200 | GCC_OPTIMIZATION_LEVEL = 0; 201 | GCC_PREPROCESSOR_DEFINITIONS = ( 202 | "DEBUG=1", 203 | "$(inherited)", 204 | ); 205 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 206 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 207 | GCC_WARN_UNDECLARED_SELECTOR = YES; 208 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 209 | GCC_WARN_UNUSED_FUNCTION = YES; 210 | GCC_WARN_UNUSED_VARIABLE = YES; 211 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 212 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 213 | MTL_FAST_MATH = YES; 214 | ONLY_ACTIVE_ARCH = YES; 215 | SDKROOT = iphoneos; 216 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 217 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 218 | }; 219 | name = Debug; 220 | }; 221 | 33E6234B27CE79DF005E399E /* Release */ = { 222 | isa = XCBuildConfiguration; 223 | buildSettings = { 224 | ALWAYS_SEARCH_USER_PATHS = NO; 225 | CLANG_ANALYZER_NONNULL = YES; 226 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 227 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 228 | CLANG_CXX_LIBRARY = "libc++"; 229 | CLANG_ENABLE_MODULES = YES; 230 | CLANG_ENABLE_OBJC_ARC = YES; 231 | CLANG_ENABLE_OBJC_WEAK = YES; 232 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 233 | CLANG_WARN_BOOL_CONVERSION = YES; 234 | CLANG_WARN_COMMA = YES; 235 | CLANG_WARN_CONSTANT_CONVERSION = YES; 236 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 237 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 238 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 239 | CLANG_WARN_EMPTY_BODY = YES; 240 | CLANG_WARN_ENUM_CONVERSION = YES; 241 | CLANG_WARN_INFINITE_RECURSION = YES; 242 | CLANG_WARN_INT_CONVERSION = YES; 243 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 244 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 245 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 246 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 247 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 248 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 249 | CLANG_WARN_STRICT_PROTOTYPES = YES; 250 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 251 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 252 | CLANG_WARN_UNREACHABLE_CODE = YES; 253 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 254 | COPY_PHASE_STRIP = NO; 255 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 256 | ENABLE_NS_ASSERTIONS = NO; 257 | ENABLE_STRICT_OBJC_MSGSEND = YES; 258 | GCC_C_LANGUAGE_STANDARD = gnu11; 259 | GCC_NO_COMMON_BLOCKS = YES; 260 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 261 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 262 | GCC_WARN_UNDECLARED_SELECTOR = YES; 263 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 264 | GCC_WARN_UNUSED_FUNCTION = YES; 265 | GCC_WARN_UNUSED_VARIABLE = YES; 266 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 267 | MTL_ENABLE_DEBUG_INFO = NO; 268 | MTL_FAST_MATH = YES; 269 | SDKROOT = iphoneos; 270 | SWIFT_COMPILATION_MODE = wholemodule; 271 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 272 | VALIDATE_PRODUCT = YES; 273 | }; 274 | name = Release; 275 | }; 276 | 33E6234D27CE79DF005E399E /* Debug */ = { 277 | isa = XCBuildConfiguration; 278 | buildSettings = { 279 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 280 | CLANG_ENABLE_MODULES = YES; 281 | CODE_SIGN_STYLE = Automatic; 282 | CURRENT_PROJECT_VERSION = 1; 283 | DEVELOPMENT_TEAM = UMBZ5WL247; 284 | ENABLE_PREVIEWS = YES; 285 | GENERATE_INFOPLIST_FILE = YES; 286 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 287 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 288 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 289 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 290 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 291 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 292 | LD_RUNPATH_SEARCH_PATHS = ( 293 | "$(inherited)", 294 | "@executable_path/Frameworks", 295 | ); 296 | MARKETING_VERSION = 1.0; 297 | PRODUCT_BUNDLE_IDENTIFIER = "com.inamiy.SherlockForms-Gallery-iOS14"; 298 | PRODUCT_NAME = "$(TARGET_NAME)"; 299 | SWIFT_EMIT_LOC_STRINGS = YES; 300 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 301 | SWIFT_VERSION = 5.0; 302 | TARGETED_DEVICE_FAMILY = "1,2"; 303 | }; 304 | name = Debug; 305 | }; 306 | 33E6234E27CE79DF005E399E /* Release */ = { 307 | isa = XCBuildConfiguration; 308 | buildSettings = { 309 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 310 | CLANG_ENABLE_MODULES = YES; 311 | CODE_SIGN_STYLE = Automatic; 312 | CURRENT_PROJECT_VERSION = 1; 313 | DEVELOPMENT_TEAM = UMBZ5WL247; 314 | ENABLE_PREVIEWS = YES; 315 | GENERATE_INFOPLIST_FILE = YES; 316 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 317 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 318 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 319 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 320 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 321 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 322 | LD_RUNPATH_SEARCH_PATHS = ( 323 | "$(inherited)", 324 | "@executable_path/Frameworks", 325 | ); 326 | MARKETING_VERSION = 1.0; 327 | PRODUCT_BUNDLE_IDENTIFIER = "com.inamiy.SherlockForms-Gallery-iOS14"; 328 | PRODUCT_NAME = "$(TARGET_NAME)"; 329 | SWIFT_EMIT_LOC_STRINGS = YES; 330 | SWIFT_VERSION = 5.0; 331 | TARGETED_DEVICE_FAMILY = "1,2"; 332 | }; 333 | name = Release; 334 | }; 335 | /* End XCBuildConfiguration section */ 336 | 337 | /* Begin XCConfigurationList section */ 338 | 33E6233927CE79DC005E399E /* Build configuration list for PBXProject "SherlockForms-Gallery-iOS14" */ = { 339 | isa = XCConfigurationList; 340 | buildConfigurations = ( 341 | 33E6234A27CE79DF005E399E /* Debug */, 342 | 33E6234B27CE79DF005E399E /* Release */, 343 | ); 344 | defaultConfigurationIsVisible = 0; 345 | defaultConfigurationName = Release; 346 | }; 347 | 33E6234C27CE79DF005E399E /* Build configuration list for PBXNativeTarget "SherlockForms-Gallery-iOS14" */ = { 348 | isa = XCConfigurationList; 349 | buildConfigurations = ( 350 | 33E6234D27CE79DF005E399E /* Debug */, 351 | 33E6234E27CE79DF005E399E /* Release */, 352 | ); 353 | defaultConfigurationIsVisible = 0; 354 | defaultConfigurationName = Release; 355 | }; 356 | /* End XCConfigurationList section */ 357 | 358 | /* Begin XCSwiftPackageProductDependency section */ 359 | 33E6235B27CE7A5F005E399E /* SherlockDebugForms */ = { 360 | isa = XCSwiftPackageProductDependency; 361 | productName = SherlockDebugForms; 362 | }; 363 | /* End XCSwiftPackageProductDependency section */ 364 | }; 365 | rootObject = 33E6233627CE79DC005E399E /* Project object */; 366 | } 367 | -------------------------------------------------------------------------------- /Examples/SherlockForms-Gallery.swiftpm/RootView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SherlockDebugForms 3 | 4 | /// NOTE: Each view that owns `SherlockForm` needs to conform to `SherlockView` protocol. 5 | @MainActor 6 | struct RootView: View, SherlockView 7 | { 8 | /// NOTE: 9 | /// `searchText` is required for `SherlockView` protocol. 10 | /// This is the only requirement to define as `@State`, and pass it to `SherlockForm`. 11 | @State public private(set) var searchText: String = "" 12 | 13 | @AppStorage(UserDefaultsStringKey.username.rawValue) 14 | private var username: String = "John Appleseed" 15 | 16 | @AppStorage(UserDefaultsStringKey.email.rawValue) 17 | private var email: String = "john@example.com" 18 | 19 | @AppStorage(UserDefaultsStringKey.password.rawValue) 20 | private var password: String = "admin" 21 | 22 | @AppStorage(UserDefaultsStringKey.languageSelection.rawValue) 23 | private var languageSelection: String = Constant.languages[0] 24 | 25 | /// Index of `Constant.languages`. 26 | @AppStorage(UserDefaultsIntKey.languageIntSelection.rawValue) 27 | private var languageIntSelection: Int = 0 28 | 29 | @AppStorage(UserDefaultsStringKey.status.rawValue) 30 | private var status = Constant.Status.online 31 | 32 | @AppStorage(UserDefaultsBoolKey.lowPowerMode.rawValue) 33 | private var isLowPowerOn: Bool = false 34 | 35 | @AppStorage(UserDefaultsBoolKey.slowAnimation.rawValue) 36 | private var isSlowAnimation: Bool = false 37 | 38 | @AppStorage(UserDefaultsDoubleKey.speed.rawValue) 39 | private var speed: Double = 1.0 40 | 41 | @AppStorage(UserDefaultsDoubleKey.fontSize.rawValue) 42 | private var fontSize: Double = 10 43 | 44 | @AppStorage(UserDefaultsDateKey.birthday.rawValue) 45 | private var birthday: SherlockDate = .init() 46 | 47 | @AppStorage(UserDefaultsDateKey.alarm.rawValue) 48 | private var alarmDate: SherlockDate = .init() 49 | 50 | @AppStorage(UserDefaultsStringKey.testLongUserDefaults.rawValue) 51 | private var stringForTestingLongUserDefault: String = "" 52 | 53 | /// - Note: 54 | /// Attaching `.enableSherlockHUD(true)` to topmost view will allow using `showHUD`. 55 | /// See `SherlockHUD` module for more information. 56 | @Environment(\.showHUD) 57 | private var showHUD: @MainActor (HUDMessage) -> Void 58 | 59 | var body: some View 60 | { 61 | // NOTE: 62 | // `SherlockForm` and `xxxCell` is where all the search magic is happening! 63 | // Just treat `SherlockForm` as a normal `Form`, and use `Section` and plain SwiftUI views accordingly. 64 | SherlockForm(searchText: $searchText) { 65 | // Simple form cells. 66 | Section { 67 | // Customized form cell using `vstackCell`. 68 | // NOTE: Combination of `SherlockForm` and `ContainerCell` is the secret of `keyword`-based searching. 69 | customVStackCell 70 | 71 | // Built-in form cells (using `hstackCell` internally). 72 | // See `FormCells` source directory for more info. 73 | textCell(icon: Image(systemName: "person.fill"), title: "User", value: username) 74 | arrayPickerCell(icon: Image(systemName: "character.bubble"), title: "Language", selection: $languageSelection, values: Constant.languages) 75 | casePickerCell(icon: Image(systemName: "person.badge.clock"), title: "Status", selection: $status) 76 | toggleCell(icon: Image(systemName: "battery.25"), title: "Low Power Mode", isOn: $isLowPowerOn) 77 | } header: { 78 | Text("Simple form cells") 79 | } footer: { 80 | if searchText.isEmpty { 81 | Text("Tip: Long-press cells to copy!") 82 | } 83 | } 84 | 85 | // More form cells 86 | Section { 87 | textFieldCell(icon: Image(systemName: "character.cursor.ibeam"), title: "Editable", value: $username) { 88 | $0 89 | .multilineTextAlignment(.trailing) 90 | .textFieldStyle(RoundedBorderTextFieldStyle()) 91 | } 92 | 93 | textEditorCell(icon: Image(systemName: "character.cursor.ibeam"), title: "Multiline Editable", value: $username) { 94 | $0 95 | .multilineTextAlignment(.trailing) 96 | .frame(maxHeight: 100) 97 | } 98 | 99 | // Array picker cell that uses `languageIntSelection` (index) as state. 100 | arrayPickerCell( 101 | icon: Image(systemName: "filemenu.and.cursorarrow"), 102 | title: "Int Picker", 103 | selection: Binding( 104 | get: { Constant.languages[languageIntSelection] }, 105 | set: { newValue in 106 | guard let index = Constant.languages.firstIndex(of: newValue) else { return } 107 | languageIntSelection = index 108 | } 109 | ), 110 | values: Constant.languages 111 | ) 112 | 113 | arrayPickerCell( 114 | icon: Image(systemName: "filemenu.and.cursorarrow"), 115 | title: "Async Picker", 116 | selection: $languageSelection, 117 | accessory: { 118 | Text("Default") 119 | .foregroundColor(.gray) 120 | Spacer().frame(width: 8) 121 | ProgressView() 122 | }, 123 | action: { 124 | // Simulating async work... 125 | try await Task.sleep(nanoseconds: 3_000_000_000) 126 | return Constant.languages 127 | }, 128 | valueType: String.self 129 | ) 130 | 131 | sliderCell( 132 | icon: Image(systemName: "speedometer"), 133 | title: "Speed", 134 | value: $speed, 135 | in: 0.5 ... 2.0, 136 | step: 0.1, 137 | maxFractionDigits: 1, 138 | valueString: { "x\($0)" }, 139 | sliderLabel: { EmptyView() }, 140 | minimumValueLabel: { Image(systemName: "tortoise") }, 141 | maximumValueLabel: { Image(systemName: "hare") }, 142 | onEditingChanged: { print("onEditingChanged", $0) } 143 | ) 144 | 145 | stepperCell( 146 | icon: Image(systemName: "textformat.size"), 147 | title: "Font Size", 148 | value: $fontSize, 149 | in: 8 ... 24, 150 | step: 1, 151 | maxFractionDigits: 0, 152 | valueString: { "\($0) pt" } 153 | ) 154 | 155 | datePickerCell( 156 | icon: Image(systemName: "birthday.cake"), 157 | title: "Birthday", 158 | selection: $birthday.date, 159 | in: .distantPast ... Date(), 160 | displayedComponents: .date 161 | ) 162 | 163 | datePickerCell( 164 | icon: Image(systemName: "alarm"), 165 | title: "Alarm", 166 | selection: $alarmDate.date, 167 | displayedComponents: [.hourAndMinute, .date] 168 | ) 169 | } header: { 170 | Text("More form cells") 171 | } 172 | 173 | // NOTE: 174 | // `hstackCell` is useful for more customizable HStack 175 | // such as manually registering search keywords and configuring context-menu. 176 | // 177 | // Here, `hstackCell` is used with default configuration, which automatically hides 178 | // whenever search happens, and no context-menu is set. 179 | Section { 180 | hstackCell { 181 | Text("Email").frame(width: 80, alignment: .leading) 182 | Spacer(minLength: 16) 183 | TextField("Input Email", text: $email) 184 | } 185 | 186 | hstackCell { 187 | Text("Password").frame(width: 80, alignment: .leading) 188 | Spacer(minLength: 16) 189 | SecureField("Input Password", text: $password) 190 | } 191 | } header: { 192 | Text("HStack Cell (More customizable)") 193 | } 194 | 195 | // Navigation Link Cell (`navigationLinkCell`) 196 | Section { 197 | navigationLinkCell( 198 | icon: Image(systemName: "person.fill"), 199 | title: "UserDefaults", 200 | destination: { 201 | UserDefaultsListView( 202 | editConfiguration: .init( 203 | boolKeys: Array(UserDefaultsBoolKey.allCases.map(\.rawValue)), 204 | stringKeys: Array(UserDefaultsStringKey.allCases.map(\.rawValue)), 205 | dateKeys: Array(UserDefaultsDateKey.allCases.map(\.rawValue)), 206 | intKeys: Array(UserDefaultsIntKey.allCases.map(\.rawValue)), 207 | doubleKeys: Array(UserDefaultsDoubleKey.allCases.map(\.rawValue)) 208 | ) 209 | ) 210 | } 211 | ) 212 | navigationLinkCell( 213 | icon: Image(systemName: "app.badge"), 214 | title: "App Info", 215 | destination: { AppInfoView() } 216 | ) 217 | navigationLinkCell( 218 | icon: Image(systemName: "iphone"), 219 | title: "Device Info", 220 | destination: { DeviceInfoView() } 221 | ) 222 | navigationLinkCell(icon: Image(systemName: "doc.richtext"), title: "Custom Page", destination: { 223 | CustomView() 224 | }) 225 | navigationLinkCell(icon: Image(systemName: "doc.richtext"), title: "Custom Page (Recursive)", destination: RootView.init) 226 | navigationLinkCell(icon: Image(systemName: "list.bullet"), title: "Simple List", destination: { 227 | ListView() 228 | }) 229 | navigationLinkCell(icon: Image(systemName: "list.bullet.indent"), title: "Nested List", destination: { 230 | NestedListView() 231 | }) 232 | } header: { 233 | Text("Navigation Link Cell") 234 | } footer: { 235 | if searchText.isEmpty { 236 | Text("Tip: Custom page (even this page) is just a plain SwiftUI View.") 237 | } 238 | } 239 | 240 | // Buttons (`buttonCell`) 241 | Section { 242 | buttonCell( 243 | icon: Image(systemName: "trash"), 244 | title: "Reset UserDefaults", 245 | action: { 246 | Helper.deleteUserDefaults() 247 | showHUD(.init(message: "Finished resetting UserDefaults")) 248 | } 249 | ) 250 | 251 | buttonCell( 252 | icon: Image(systemName: "trash"), 253 | title: "Delete Caches", 254 | action: { 255 | // Fake long task... 256 | try await Task.sleep(nanoseconds: 1_000_000_000) 257 | 258 | try? Helper.deleteAllCaches() 259 | showHUD(.init(message: "Finished deleting caches")) 260 | } 261 | ) 262 | 263 | if #available(iOS 15.0, *) { 264 | // `buttonCell` with `confirmationDialog`. 265 | buttonDialogCell( 266 | icon: Image(systemName: "trash"), 267 | title: "Delete All Contents", 268 | dialogTitle: nil, 269 | dialogButtons: [ 270 | .init(title: "Delete All Contents", role: .destructive) { 271 | // Fake long task... 272 | try await Task.sleep(nanoseconds: 2_000_000_000) 273 | 274 | try? Helper.deleteAllFilesAndCaches() 275 | showHUD(.init(message: "Finished deleting all contents")) 276 | }, 277 | .init(title: "Cancel", role: .cancel) { 278 | print("Cancelled") 279 | } 280 | ] 281 | ) 282 | } 283 | else { 284 | buttonCell(icon: Image(systemName: "person.fill"), title: "Delete All Contents", action: { 285 | try? Helper.deleteAllFilesAndCaches() 286 | }) 287 | } 288 | } header: { 289 | Text("Buttons") 290 | } footer: { 291 | if searchText.isEmpty { 292 | Text("Tip: Last button is ButtonDialog.") 293 | } 294 | } 295 | 296 | // Slow motion (`toggleCell`) 297 | Section { 298 | toggleCell(icon: Image(systemName: "figure.roll"), title: "Slow Animation", isOn: $isSlowAnimation) 299 | .onChange(of: isSlowAnimation) { isSlowAnimation in 300 | // Workaround: 301 | // Immediately setting animation speed after `Toggle` change will cause 302 | // its malformed UI, so add `sleep` to workaround (NOTE: 500 ms is not enough). 303 | Task { @MainActor in 304 | try await Task.sleep(nanoseconds: 1_000_000_000) 305 | setAnimationSpeed(isSlowAnimation: isSlowAnimation) 306 | } 307 | } 308 | .onAppear { 309 | setAnimationSpeed(isSlowAnimation: isSlowAnimation) 310 | } 311 | } header: { 312 | Text("Slow motion") 313 | } 314 | 315 | // Full-Text Search Result: 316 | // Show navigationLink's search results as well. 317 | if !searchText.isEmpty { 318 | UserDefaultsListSectionsView( 319 | searchText: searchText, 320 | maxRecentlyUsedCount: 0, 321 | sectionHeader: { sectionHeader(prefixes: "UserDefaults", title: $0) } 322 | ) 323 | AppInfoSectionsView( 324 | searchText: searchText, 325 | sectionHeader: { sectionHeader(prefixes: "App Info", title: $0) } 326 | ) 327 | DeviceInfoSectionsView( 328 | searchText: searchText, 329 | sectionHeader: { sectionHeader(prefixes: "Device Info", title: $0) } 330 | ) 331 | } 332 | } 333 | .navigationTitle("Settings") 334 | // NOTE: 335 | // Use `formCellCopyable` here (as a wrapper of entire `SherlockForm`) to allow ALL `xxxCell`s to be copyable. 336 | // To Make each cell copyable one by one instead, call it as a wrapper of each form cell. 337 | .formCellCopyable(true) 338 | // For aligning icons and texts horizontally. 339 | .formCellIconWidth(30) 340 | } 341 | 342 | /// Customized form cell using `vstackCell`. 343 | @ViewBuilder 344 | private var customVStackCell: some View 345 | { 346 | vstackCell( 347 | keywords: "Add", "your", "favorite", "keywords", "as much as possible", "Hello", "SherlockForms", 348 | copyableKeyValue: .init(key: "Hello SherlockForms!"), 349 | alignment: .center, 350 | content: { 351 | Text("🕵️‍♂️").font(.system(size: 48)) 352 | Text("Hello SherlockForms!").font(.title) 353 | } 354 | ) 355 | // NOTE: 356 | // `formCellContentModifier` allows to modify `cellContent` that wraps `vstackCell`'s `content`). 357 | // 358 | // This method may sometimes be needed for SwiftUI View method-chaining 359 | // to NOT start from "receiver" but from its `cellContent`. 360 | .formCellContentModifier { cellContent in 361 | cellContent 362 | .frame(maxWidth: .greatestFiniteMagnitude, maxHeight: 150) 363 | .padding() 364 | .onTapGesture { 365 | print("Hello SherlockForms!") 366 | } 367 | } 368 | } 369 | } 370 | 371 | // MARK: - Private 372 | 373 | private func sectionHeader(prefixes: String..., title: String) -> String 374 | { 375 | (prefixes + [title]).filter { !$0.isEmpty }.joined(separator: " > ") 376 | } 377 | 378 | @MainActor 379 | private func setAnimationSpeed(isSlowAnimation: Bool) 380 | { 381 | if isSlowAnimation { 382 | Helper.setAnimationSpeed(0.1) 383 | } 384 | else { 385 | Helper.setAnimationSpeed(1) 386 | } 387 | } 388 | 389 | // MARK: - Previews 390 | 391 | struct RootView_Previews: PreviewProvider 392 | { 393 | static var previews: some View 394 | { 395 | RootView() 396 | } 397 | } 398 | 399 | -------------------------------------------------------------------------------- /Sources/SherlockDebugForms/UserDefaultsListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SherlockForms 3 | 4 | /// UserDefaults viewer. 5 | /// 6 | /// # Known issue 7 | /// - After presenting `DatePicker`'s popup, `showDetail` doesn' work properly (possibly due to SwiftUI bug). 8 | public struct UserDefaultsListView: View 9 | { 10 | @State private var searchText: String = "" 11 | 12 | private let userDefaults: UserDefaults 13 | 14 | /// UserDefaults editable key-hints configuration to allow inline editing. 15 | private let editConfiguration: EditConfiguration 16 | 17 | /// Custom list filtering. 18 | private let listFilter: ListFilter? 19 | 20 | private let maxCellHeight: CGFloat 21 | private let maxRecentlyUsedCount: Int 22 | 23 | public init( 24 | userDefaults: UserDefaults = .standard, 25 | listFilter: ListFilter? = nil, 26 | editConfiguration: EditConfiguration = .init(), 27 | maxCellHeight: CGFloat = 100, 28 | maxRecentlyUsedCount: Int = 3 29 | ) 30 | { 31 | self.userDefaults = userDefaults 32 | self.listFilter = listFilter 33 | self.editConfiguration = editConfiguration 34 | self.maxCellHeight = maxCellHeight 35 | self.maxRecentlyUsedCount = maxRecentlyUsedCount 36 | } 37 | 38 | public var body: some View 39 | { 40 | SherlockForm(searchText: $searchText) { 41 | UserDefaultsListSectionsView( 42 | searchText: searchText, 43 | userDefaults: userDefaults, 44 | listFilter: listFilter, 45 | editConfiguration: editConfiguration, 46 | maxCellHeight: maxCellHeight, 47 | maxRecentlyUsedCount: maxRecentlyUsedCount 48 | ) 49 | } 50 | .navigationTitle("UserDefaults") 51 | .navigationBarTitleDisplayMode(.inline) 52 | } 53 | 54 | // MARK: Inner Types 55 | 56 | public typealias Key = String 57 | public typealias Value = Any 58 | public typealias KeyValue = (key: Key, value: Value) 59 | 60 | public typealias ListFilter = (_ searchText: String, _ keywords: [String]) -> Bool 61 | 62 | /// UserDefaults editable key-hints configuration to allow inline editing. 63 | public struct EditConfiguration 64 | { 65 | public var boolKeys: [Key] 66 | public var stringKeys: [Key] 67 | public var dateKeys: [Key] 68 | public var intKeys: [Key] 69 | public var doubleKeys: [Key] 70 | 71 | public init( 72 | boolKeys: [Key] = [], 73 | stringKeys: [Key] = [], 74 | dateKeys: [Key] = [], 75 | intKeys: [Key] = [], 76 | doubleKeys: [Key] = [] 77 | ) 78 | { 79 | self.boolKeys = boolKeys 80 | self.stringKeys = stringKeys 81 | self.dateKeys = dateKeys 82 | self.intKeys = intKeys 83 | self.doubleKeys = doubleKeys 84 | } 85 | } 86 | } 87 | 88 | /// UserDefaults `Section`s, useful for presenting search results from ancestor screens. 89 | @MainActor 90 | public struct UserDefaultsListSectionsView: View, SherlockView 91 | { 92 | @StateObject private var notifier: UserDefaultsNotifier = .init() 93 | 94 | public let searchText: String 95 | 96 | @State private var keyValues: [KeyValue] = [] 97 | 98 | @AppStorage("com.inamiy.SherlockForms.UserDefaults.recently-used-keys") 99 | private var recentlyUsedKeys: Strings = .init() 100 | 101 | @State private var presentingKey: String? 102 | 103 | private let userDefaults: UserDefaults 104 | 105 | private let editConfiguration: EditConfiguration 106 | 107 | /// Custom list filtering. 108 | private let listFilter: ListFilter? 109 | 110 | private let maxCellHeight: CGFloat 111 | private let maxRecentlyUsedCount: Int 112 | 113 | private let sectionHeader: (String) -> String 114 | 115 | @Environment(\.showHUD) 116 | private var showHUD: @MainActor (HUDMessage) -> Void 117 | 118 | public init( 119 | searchText: String, 120 | userDefaults: UserDefaults = .standard, 121 | listFilter: ListFilter? = nil, 122 | editConfiguration: EditConfiguration = .init(), 123 | maxCellHeight: CGFloat = 100, 124 | maxRecentlyUsedCount: Int = 3, 125 | sectionHeader: @escaping (String) -> String = { $0 } 126 | ) 127 | { 128 | self.searchText = searchText 129 | self.userDefaults = userDefaults 130 | 131 | self.keyValues = userDefaults.getKeyValues() 132 | 133 | self.listFilter = listFilter 134 | self.editConfiguration = editConfiguration 135 | self.maxCellHeight = maxCellHeight 136 | self.maxRecentlyUsedCount = maxRecentlyUsedCount 137 | self.sectionHeader = sectionHeader 138 | } 139 | 140 | public var body: some View 141 | { 142 | let sections = Group { 143 | if !recentlyUsedKeys.strings.isEmpty && searchText.isEmpty && maxRecentlyUsedCount > 0 { 144 | Section { 145 | recentlyUsedKeyValuesView 146 | } header: { 147 | sectionHeaderView(sectionHeader("Recent")) 148 | } 149 | } 150 | 151 | Section { 152 | allKeyValuesView 153 | } header: { 154 | sectionHeaderView(sectionHeader("All")) 155 | } 156 | } 157 | .onReceive(notifier.objectWillChange) { _ in 158 | keyValues = userDefaults.getKeyValues() 159 | } 160 | 161 | if #available(iOS 15.0, *) { 162 | sections 163 | .sheet(unwrapping: $presentingKey) { keyBinding in 164 | let key = keyBinding.wrappedValue 165 | if let index = keyValues.firstIndex(where: { $0.key == key }) { 166 | let value = keyValues[index].value 167 | UserDefaultsItemView(key: key, value: value, userDefaults: userDefaults) 168 | } 169 | } 170 | } 171 | else { 172 | // Workaround for iOS 14 `Form`'s inner view + `sheet` breaking table layout. 173 | sections 174 | .background( 175 | EmptyView() 176 | .sheet(unwrapping: $presentingKey) { keyBinding in 177 | let key = keyBinding.wrappedValue 178 | if let index = keyValues.firstIndex(where: { $0.key == key }) { 179 | let value = keyValues[index].value 180 | UserDefaultsItemView(key: key, value: value, userDefaults: userDefaults) 181 | } 182 | } 183 | ) 184 | } 185 | } 186 | 187 | @ViewBuilder 188 | private var recentlyUsedKeyValuesView: some View 189 | { 190 | let recentKeyValues: [KeyValue] = recentlyUsedKeys.strings.reversed() 191 | .compactMap { key in 192 | keyValues.first(where: { $0.key == key }) 193 | } 194 | 195 | keyValuesView(keyValues: recentKeyValues) 196 | } 197 | 198 | @ViewBuilder 199 | private var allKeyValuesView: some View 200 | { 201 | keyValuesView(keyValues: keyValues) 202 | } 203 | 204 | @ViewBuilder 205 | private func keyValuesView(keyValues: [KeyValue]) -> some View 206 | { 207 | ForEach(keyValues, id: \.key) { key, value in 208 | if editConfiguration.boolKeys.contains(key) { 209 | if let bool = value as? Bool { 210 | boolValueCell(key: key, bool: bool) 211 | } 212 | else { 213 | defaultCell(key: key, value: value) 214 | } 215 | } 216 | else if editConfiguration.stringKeys.contains(key) { 217 | if let string = value as? String { 218 | stringValueCell(key: key, string: string) 219 | } 220 | else { 221 | defaultCell(key: key, value: value) 222 | } 223 | } 224 | else if editConfiguration.dateKeys.contains(key) { 225 | if let date_ = value as? Date 226 | { 227 | dateValueCell(key: key, date: SherlockDate(date_)) 228 | } 229 | else if let string = value as? String, 230 | let date = SherlockDate(rawValue: string) 231 | { 232 | dateValueCell(key: key, date: date) 233 | } 234 | else { 235 | defaultCell(key: key, value: value) 236 | } 237 | } 238 | else if editConfiguration.intKeys.contains(key) { 239 | if let int = value as? Int { 240 | intValueCell(key: key, int: int) 241 | } 242 | else { 243 | defaultCell(key: key, value: value) 244 | } 245 | } 246 | else if editConfiguration.doubleKeys.contains(key) { 247 | if let double = value as? Double { 248 | doubleValueCell(key: key, double: double) 249 | } 250 | else { 251 | defaultCell(key: key, value: value) 252 | } 253 | } 254 | else { 255 | defaultCell(key: key, value: value) 256 | } 257 | } 258 | } 259 | 260 | @ViewBuilder 261 | private func boolValueCell(key: Key, bool: Bool) -> some View 262 | { 263 | toggleCell(title: key, isOn: Binding(get: { bool }, set: { newValue in 264 | userDefaults.set(newValue, forKey: key) 265 | insertRecentlyUsedKey(key) 266 | self.keyValues = userDefaults.getKeyValues() 267 | })) 268 | .formCellContentModifier(contextMenuModifier(key: key, value: bool)) 269 | } 270 | 271 | @ViewBuilder 272 | private func stringValueCell(key: Key, string: String) -> some View 273 | { 274 | textFieldCell( 275 | title: key, 276 | value: Binding(get: { string }, set: { newValue in 277 | userDefaults.set(newValue, forKey: key) 278 | insertRecentlyUsedKey(key) 279 | self.keyValues = userDefaults.getKeyValues() 280 | }), 281 | modify: { 282 | $0 283 | .onTapGesture { /* Don't let wrapper view to steal this tap */ } 284 | .frame(minWidth: 100, maxWidth: 200) 285 | .multilineTextAlignment(.trailing) 286 | .lineLimit(4) 287 | .keyboardType(.default) 288 | .textFieldStyle(RoundedBorderTextFieldStyle()) 289 | } 290 | ) 291 | .formCellContentModifier(contextMenuModifier(key: key, value: string)) 292 | } 293 | 294 | @ViewBuilder 295 | private func dateValueCell(key: Key, date: SherlockDate) -> some View 296 | { 297 | datePickerCell( 298 | title: key, 299 | selection: Binding(get: { date.date }, set: { newValue in 300 | let date = SherlockDate(newValue) 301 | 302 | userDefaults.set(date.rawValue, forKey: key) 303 | insertRecentlyUsedKey(key) 304 | self.keyValues = userDefaults.getKeyValues() 305 | }), 306 | displayedComponents: [.date, .hourAndMinute] 307 | ) 308 | .formCellContentModifier(contextMenuModifier(key: key, value: date.rawValue)) 309 | } 310 | 311 | @ViewBuilder 312 | private func intValueCell(key: Key, int: Int) -> some View 313 | { 314 | textFieldCell( 315 | title: key, 316 | value: Binding(get: { "\(int)" }, set: { newValue in 317 | guard let int = Int(newValue) else { return } 318 | 319 | userDefaults.set(int, forKey: key) 320 | insertRecentlyUsedKey(key) 321 | self.keyValues = userDefaults.getKeyValues() 322 | }), 323 | modify: { 324 | $0 325 | .onTapGesture { /* Don't let wrapper view to steal this tap */ } 326 | .frame(minWidth: 100, maxWidth: 200) 327 | .multilineTextAlignment(.trailing) 328 | .keyboardType(.numberPad) 329 | .textFieldStyle(RoundedBorderTextFieldStyle()) 330 | } 331 | ) 332 | .formCellContentModifier(contextMenuModifier(key: key, value: int)) 333 | } 334 | 335 | @ViewBuilder 336 | private func doubleValueCell(key: Key, double: Double) -> some View 337 | { 338 | textFieldCell( 339 | title: key, 340 | value: Binding(get: { "\(double)" }, set: { newValue in 341 | guard let double = Double(newValue) else { return } 342 | 343 | userDefaults.set(double, forKey: key) 344 | insertRecentlyUsedKey(key) 345 | self.keyValues = userDefaults.getKeyValues() 346 | }), 347 | modify: { 348 | $0 349 | .onTapGesture { /* Don't let wrapper view to steal this tap */ } 350 | .frame(minWidth: 100, maxWidth: 200) 351 | .multilineTextAlignment(.trailing) 352 | .keyboardType(.decimalPad) 353 | .textFieldStyle(RoundedBorderTextFieldStyle()) 354 | } 355 | ) 356 | .formCellContentModifier(contextMenuModifier(key: key, value: double)) 357 | } 358 | 359 | @ViewBuilder 360 | private func defaultCell(key: Key, value: Value) -> some View 361 | { 362 | textCell(title: key, value: value) 363 | .formCellContentModifier(contextMenuModifier(key: key, value: value)) 364 | } 365 | 366 | private func contextMenuModifier(key: Key, value: Value) -> AnyViewModifier { 367 | AnyViewModifier { content in 368 | // WARNING: 369 | // `formCellContentModifier` is required for custom `contextMenu` attachment. 370 | // If below SwiftUI-method-chain is attached directly to `textCell` instead of `cellContent`, 371 | // malformed search result will appear. 372 | content 373 | .frame(maxWidth: .infinity, maxHeight: maxCellHeight) 374 | .contentShape(Rectangle()) // Improves tap for empty space. 375 | .onTapGesture { 376 | if let firstResponder = UIView.currentFirstResponder() { 377 | firstResponder.resignFirstResponder() 378 | } 379 | else { 380 | showDetail(key: key) 381 | } 382 | } 383 | .contextMenu { 384 | Button { copyAsString(key) } label: { 385 | Label("Copy Key", systemImage: "doc.on.doc") 386 | } 387 | 388 | Button { copyAsString(value) } label: { 389 | Label("Copy Value", systemImage: "doc.on.doc") 390 | } 391 | 392 | Button { showDetail(key: key) } label: { 393 | Label("Show Detail", systemImage: "doc.text.magnifyingglass") 394 | } 395 | 396 | Button { deleteKey(key) } label: { 397 | Label("Delete", systemImage: "trash") 398 | } 399 | } 400 | } 401 | } 402 | 403 | public func canShowCell(keywords: [String]) -> Bool 404 | { 405 | if let listFilter = listFilter { 406 | return listFilter(searchText, keywords) 407 | } 408 | 409 | for keyword in keywords { 410 | if searchText.isEmpty || keyword.lowercased().contains(searchText.lowercased()) { 411 | return true 412 | } 413 | } 414 | return false 415 | } 416 | 417 | private func copyAsString(_ value: Value) 418 | { 419 | let string = String(describing: value) 420 | 421 | UIPasteboard.general.string = string 422 | 423 | showHUD( 424 | .init( 425 | message: "Copied \"\(string.truncated(maxCount: 50))\"", 426 | duration: 2, 427 | alignment: .bottom 428 | ) 429 | ) 430 | } 431 | 432 | private func deleteKey(_ key: String) 433 | { 434 | userDefaults.removeObject(forKey: key) 435 | 436 | for i in keyValues.indices.reversed() where keyValues[i].key == key { 437 | keyValues.remove(at: i) 438 | } 439 | 440 | showHUD( 441 | .init( 442 | message: "Deleted \"\(key.truncated(maxCount: 50))\"", 443 | duration: 2, 444 | alignment: .bottom 445 | ) 446 | ) 447 | } 448 | 449 | private func showDetail(key: String) 450 | { 451 | // Present modally. 452 | presentingKey = key 453 | 454 | Task { 455 | // Wait for modal animation. 456 | try await Task.sleep(nanoseconds: 300_000_000) 457 | 458 | // Then, update `recentlyUsedKeys`. 459 | insertRecentlyUsedKey(key) 460 | } 461 | } 462 | 463 | private func insertRecentlyUsedKey(_ key: String) 464 | { 465 | guard maxRecentlyUsedCount > 0 else { return } 466 | 467 | var keys = recentlyUsedKeys.strings 468 | keys.removeAll(where: { $0 == key }) 469 | keys = Array(keys.suffix(maxRecentlyUsedCount - 1)) 470 | keys.append(key) 471 | recentlyUsedKeys.strings = keys 472 | } 473 | 474 | public typealias Key = UserDefaultsListView.Key 475 | public typealias Value = UserDefaultsListView.Value 476 | public typealias KeyValue = UserDefaultsListView.KeyValue 477 | public typealias ListFilter = UserDefaultsListView.ListFilter 478 | public typealias EditConfiguration = UserDefaultsListView.EditConfiguration 479 | } 480 | 481 | // MARK: - Strings 482 | 483 | /// `@AppStorage`-persistable keys. 484 | private struct Strings: Codable 485 | { 486 | var strings: [String] = [] 487 | } 488 | 489 | extension Strings: RawRepresentable 490 | { 491 | public init?(rawValue: String) 492 | { 493 | guard let data = rawValue.data(using: .utf8), 494 | let result = try? JSONDecoder().decode([String].self, from: data) 495 | else { 496 | return nil 497 | } 498 | self.strings = result 499 | } 500 | 501 | public var rawValue: String 502 | { 503 | guard let data = try? JSONEncoder().encode(self.strings), 504 | let result = String(data: data, encoding: .utf8) 505 | else { 506 | return "[]" 507 | } 508 | return result 509 | } 510 | } 511 | 512 | // MARK: - Private 513 | 514 | extension UserDefaults 515 | { 516 | func getKeyValues() -> [UserDefaultsListView.KeyValue] 517 | { 518 | dictionaryRepresentation() 519 | .sorted(by: { $0.0 < $1.0 }) 520 | } 521 | } 522 | 523 | private final class UserDefaultsNotifier : ObservableObject 524 | { 525 | let objectWillChange = NotificationCenter.default 526 | .publisher(for: UserDefaults.didChangeNotification, object: nil) 527 | 528 | init() {} 529 | } 530 | --------------------------------------------------------------------------------