├── .gitignore ├── DocPlaygroundsApp.swiftpm ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon-1024.png │ │ ├── AppIcon-60@2x.png │ │ ├── AppIcon-60@3x.png │ │ ├── AppIcon-76@2x.png │ │ ├── AppIcon-83.5@2x.png │ │ └── Contents.json │ └── Contents.json ├── Checklist.swift ├── ChecklistDocument.swift ├── ContentView.swift ├── MoreInfo.plist ├── MyApp.swift └── Package.swift ├── README.md └── screenshots.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemPinkColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/DocumentBasedSwiftPlaygroundsApp/1d8d9bb62ad21e2bebadbb2ff177dc81a11c2f71/DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/DocumentBasedSwiftPlaygroundsApp/1d8d9bb62ad21e2bebadbb2ff177dc81a11c2f71/DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/DocumentBasedSwiftPlaygroundsApp/1d8d9bb62ad21e2bebadbb2ff177dc81a11c2f71/DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/DocumentBasedSwiftPlaygroundsApp/1d8d9bb62ad21e2bebadbb2ff177dc81a11c2f71/DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/DocumentBasedSwiftPlaygroundsApp/1d8d9bb62ad21e2bebadbb2ff177dc81a11c2f71/DocPlaygroundsApp.swiftpm/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png -------------------------------------------------------------------------------- /DocPlaygroundsApp.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 | "filename" : "AppIcon-60@2x.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "60x60" 38 | }, 39 | { 40 | "filename" : "AppIcon-60@3x.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "idiom" : "ipad", 47 | "scale" : "1x", 48 | "size" : "20x20" 49 | }, 50 | { 51 | "idiom" : "ipad", 52 | "scale" : "2x", 53 | "size" : "20x20" 54 | }, 55 | { 56 | "idiom" : "ipad", 57 | "scale" : "1x", 58 | "size" : "29x29" 59 | }, 60 | { 61 | "idiom" : "ipad", 62 | "scale" : "2x", 63 | "size" : "29x29" 64 | }, 65 | { 66 | "idiom" : "ipad", 67 | "scale" : "1x", 68 | "size" : "40x40" 69 | }, 70 | { 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "40x40" 74 | }, 75 | { 76 | "idiom" : "ipad", 77 | "scale" : "1x", 78 | "size" : "76x76" 79 | }, 80 | { 81 | "filename" : "AppIcon-76@2x.png", 82 | "idiom" : "ipad", 83 | "scale" : "2x", 84 | "size" : "76x76" 85 | }, 86 | { 87 | "filename" : "AppIcon-83.5@2x.png", 88 | "idiom" : "ipad", 89 | "scale" : "2x", 90 | "size" : "83.5x83.5" 91 | }, 92 | { 93 | "filename" : "AppIcon-1024.png", 94 | "idiom" : "ios-marketing", 95 | "scale" : "1x", 96 | "size" : "1024x1024" 97 | } 98 | ], 99 | "info" : { 100 | "author" : "xcode", 101 | "version" : 1 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/Checklist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // DocPlaygroundsApp 4 | // 5 | // Created by Guilherme Rambo on 28/12/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Checklist: Identifiable, Hashable, Codable { 11 | struct Item: Identifiable, Hashable, Codable { 12 | let id: UUID 13 | var title: String 14 | var isDone: Bool 15 | } 16 | 17 | let id: UUID 18 | var items: [Item] 19 | } 20 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/ChecklistDocument.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // DocPlaygroundsApp 4 | // 5 | // Created by Guilherme Rambo on 28/12/21. 6 | // 7 | 8 | import Foundation 9 | import UniformTypeIdentifiers 10 | import SwiftUI 11 | 12 | extension UTType { 13 | static let checklistDocument = UTType( 14 | exportedAs: "codes.rambo.sampleCode.ChecklistDocument", 15 | conformingTo: .json 16 | ) 17 | } 18 | 19 | struct ChecklistDocument: FileDocument { 20 | 21 | let title: String 22 | var checklist: Checklist 23 | 24 | static var readableContentTypes: [UTType] = [.checklistDocument] 25 | 26 | init() { 27 | self.title = "Untitled" 28 | self.checklist = Checklist(id: .init(), items: []) 29 | } 30 | 31 | init(configuration: ReadConfiguration) throws { 32 | guard let data = configuration.file.regularFileContents else { 33 | throw CocoaError(.fileReadCorruptFile) 34 | } 35 | 36 | self.title = configuration.file.nameWithoutExtension 37 | self.checklist = try JSONDecoder().decode(Checklist.self, from: data) 38 | } 39 | 40 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 41 | let data = try JSONEncoder().encode(checklist) 42 | let wrapper = FileWrapper(regularFileWithContents: data) 43 | return wrapper 44 | } 45 | 46 | mutating func delete(_ item: Checklist.Item) { 47 | guard let index = checklist.items.firstIndex(of: item) else { 48 | return 49 | } 50 | 51 | checklist.items.remove(at: index) 52 | } 53 | 54 | } 55 | 56 | extension FileWrapper { 57 | var nameWithoutExtension: String { 58 | guard let filename = filename else { return "Untitled" } 59 | 60 | // Dummy URL just to strip the file extension from the name 61 | // in a "correct" way. 62 | return URL(fileURLWithPath: "/") 63 | .appendingPathComponent(filename) 64 | .deletingPathExtension() 65 | .lastPathComponent 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | typealias Item = Checklist.Item 5 | 6 | @Binding var document: ChecklistDocument 7 | 8 | @State private var editingItemID: Item.ID? 9 | @FocusState private var editingFieldFocused 10 | 11 | var body: some View { 12 | NavigationView { itemList } 13 | } 14 | 15 | private var sortedItems: [Binding] { 16 | $document.checklist.items.sorted(by: { a, b in b.wrappedValue.isDone }) 17 | } 18 | 19 | private var itemList: some View { 20 | List(sortedItems) { $item in 21 | itemRowView(for: $item) 22 | .swipeActions(edge: .trailing) { 23 | Button(role: .destructive) { 24 | withAnimation { document.delete(item) } 25 | } label: { 26 | Text("Delete") 27 | } 28 | } 29 | .swipeActions(edge: .leading) { 30 | Button { 31 | withAnimation { item.isDone.toggle() } 32 | } label: { 33 | if item.isDone { 34 | Text("Undone") 35 | } else { 36 | Text("Done") 37 | } 38 | } 39 | .tint(.accentColor) 40 | } 41 | .accessibilityRepresentation { 42 | Toggle(item.title, isOn: $item.isDone) 43 | .accessibilityHint( 44 | item.isDone ? "Mark \"\(item.title)\" as undone" : "Mark \"\(item.title)\" as done" 45 | ) 46 | } 47 | } 48 | .toolbar { toolbarContents } 49 | .navigationTitle(document.title) 50 | } 51 | 52 | private func itemRowView(for item: Binding) -> some View { 53 | Group { 54 | HStack { 55 | checkbox(for: item) 56 | 57 | if editingItemID == item.id { 58 | TextField("Item", text: item.title, onCommit: { 59 | editingItemID = nil 60 | }) 61 | .focused($editingFieldFocused) 62 | } else { 63 | Text(item.wrappedValue.formattedTitle) 64 | .onTapGesture { 65 | editingItemID = item.id 66 | } 67 | .foregroundColor(item.wrappedValue.isDone ? .secondary : .primary) 68 | } 69 | } 70 | } 71 | .onChange(of: editingItemID) { newValue in 72 | editingFieldFocused = newValue != nil 73 | } 74 | } 75 | 76 | private func checkbox(for item: Binding) -> some View { 77 | Group { 78 | if item.wrappedValue.isDone { 79 | Image(systemName: "checkmark.square") 80 | } else { 81 | Image(systemName: "square") 82 | } 83 | } 84 | .foregroundColor(.accentColor) 85 | .onTapGesture { 86 | withAnimation { 87 | item.wrappedValue.isDone.toggle() 88 | } 89 | } 90 | } 91 | 92 | private var toolbarContents: some ToolbarContent { 93 | ToolbarItemGroup(placement: .primaryAction) { 94 | Button { 95 | let newItem = Item(id: .init(), title: "", isDone: false) 96 | document.checklist.items.append(newItem) 97 | editingItemID = newItem.id 98 | } label: { 99 | Image(systemName: "plus") 100 | } 101 | } 102 | } 103 | } 104 | 105 | extension Checklist.Item { 106 | var formattedTitle: AttributedString { 107 | if isDone { 108 | return (try? AttributedString(markdown: "~~\(title)~~")) 109 | ?? AttributedString(title) 110 | } else { 111 | return AttributedString(title) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/MoreInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | LSHandlerRank 9 | Owner 10 | CFBundleTypeName 11 | Checklist Document 12 | NSUbiquitousDocumentUserActivityType 13 | codes.rambo.sampleCode.ChecklistDocument 14 | LSItemContentTypes 15 | 16 | codes.rambo.sampleCode.ChecklistDocument 17 | 18 | 19 | 20 | UTExportedTypeDeclarations 21 | 22 | 23 | UTTypeConformsTo 24 | 25 | public.json 26 | 27 | UTTypeDescription 28 | Rambo Checklist Document 29 | UTTypeIconFiles 30 | 31 | UTTypeIdentifier 32 | codes.rambo.sampleCode.ChecklistDocument 33 | UTTypeTagSpecification 34 | 35 | public.filename-extension 36 | 37 | rcd 38 | 39 | 40 | 41 | 42 | LSSupportsOpeningDocumentsInPlace 43 | 44 | ITSAppUsesNonExemptEncryption 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.swiftpm/MyApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct MyApp: App { 5 | var body: some Scene { 6 | DocumentGroup(newDocument: ChecklistDocument()) { config in 7 | ContentView(document: config.$document) 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /DocPlaygroundsApp.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: "DocPlaygroundsApp", 12 | platforms: [ 13 | .iOS("15.2") 14 | ], 15 | products: [ 16 | .iOSApplication( 17 | name: "DocPlaygroundsApp", 18 | targets: ["AppModule"], 19 | bundleIdentifier: "codes.rambo.samplecode.DocPlaygroundsApp", 20 | teamIdentifier: "8C7439RJLG", 21 | displayVersion: "1.0", 22 | bundleVersion: "2", 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 | additionalInfoPlistContentFilePath: "MoreInfo.plist" 36 | ) 37 | ], 38 | targets: [ 39 | .executableTarget( 40 | name: "AppModule", 41 | path: ".", 42 | exclude: ["./MoreInfo.plist"] 43 | ) 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A document-based SwiftUI app in Swift Playgrounds 2 | 3 | This sample project demonstrates the creation of a document-based app in Swift Playgrounds 4 for iPad. It uses some tricks in order to declare the app's document type, which I have [detailed in this blog post](https://rambo.codes/posts/2021-12-28-a-document-based-app-in-swift-playgrounds-for-ipad/). 4 | 5 | As mentioned in the blog post, consider this a temporary hack, and not something you should rely on for production apps. 6 | 7 | ![Screenshots](./screenshots.png) -------------------------------------------------------------------------------- /screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/DocumentBasedSwiftPlaygroundsApp/1d8d9bb62ad21e2bebadbb2ff177dc81a11c2f71/screenshots.png --------------------------------------------------------------------------------