├── .gitignore ├── .github └── workflows │ └── swift.yml ├── Package.swift ├── LICENSE ├── README.md └── Sources ├── TokenFieldTests └── TokenFieldTests.swift └── TokenField ├── FormTokenField.swift └── TokenField.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-11 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "TokenField", 8 | platforms: [.macOS(.v11)], 9 | products: [ 10 | .library( 11 | name: "TokenField", 12 | targets: ["TokenField"]), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "TokenField", 17 | dependencies: []), 18 | .testTarget( 19 | name: "TokenFieldTests", 20 | dependencies: ["TokenField"]), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Fabián Cañas 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TokenField 2 | 3 | A SwiftUI wrapper for `NSTokenField` 4 | 5 | ```swift 6 | struct MyToken { 7 | var title: String 8 | } 9 | 10 | struct TestView: View { 11 | @State var tokens: [MyToken] 12 | var body: some View { 13 | VStack { 14 | TokenField($tokens, { $0.title }) 15 | } 16 | } 17 | } 18 | ``` 19 | 20 | `TokenField` takes a `Binding` to a `RandomAccessCollection` of 21 | elements to use as its tokens. When the token type does not conform 22 | to `StringProtocol`, it also requires a closure to convert tokens to 23 | a `String`. 24 | 25 | ```swift 26 | struct TestIdentifiableStringsView: View { 27 | @State var tokens: [String] 28 | var body: some View { 29 | VStack { 30 | TokenField($tokens) 31 | } 32 | } 33 | } 34 | ``` 35 | 36 | `FormTokenField` can be used within a `Form` view and be given a 37 | label that will align with standard `Form` labels. 38 | 39 | ```swift 40 | struct TestFormsView: View { 41 | @State var tokens: [MyToken] 42 | @State var strings: [String] 43 | var body: some View { 44 | Form { 45 | FormTokenField(title:"Tokens", $tokens, { $0.title }) 46 | FormTokenField({ Text("Tokens") }, $strings) 47 | } 48 | } 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /Sources/TokenFieldTests/TokenFieldTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // File 4 | // 5 | // Created by Fabian Canas on 8/24/21. 6 | // 7 | 8 | import SwiftUI 9 | import TokenField 10 | import XCTest 11 | 12 | struct MyToken { 13 | var title: String 14 | } 15 | 16 | // This compiling is the test 17 | struct TestView: View { 18 | @State var tokens: [MyToken] 19 | var body: some View { 20 | VStack { 21 | TokenField($tokens, { $0.title }) 22 | } 23 | } 24 | } 25 | 26 | /// This compiling is the test 27 | struct TestStringsView: View { 28 | @State var tokens: [String] 29 | var body: some View { 30 | VStack { 31 | TokenField($tokens) 32 | } 33 | } 34 | } 35 | 36 | struct TestFormsView: View { 37 | @State var tokens: [MyToken] 38 | @State var strings: [String] 39 | var body: some View { 40 | Form { 41 | FormTokenField(title:"Tokens", $tokens, { $0.title }) 42 | FormTokenField({ Text("Tokens") }, $strings) 43 | } 44 | } 45 | } 46 | 47 | class TokenFieldTests: XCTestCase { 48 | func testInstatiationWithStringArray() throws { 49 | _ = TokenField(.constant(Array())) 50 | } 51 | func testInstatiationWithIntArray() throws { 52 | _ = TokenField(.constant(Array()), { String($0) }) 53 | } 54 | func testFormsView() throws { 55 | _ = TestFormsView(tokens: [MyToken(title: "something")], strings: ["something else", "and more"]) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/TokenField/FormTokenField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormTokenField.swift 3 | // FormTokenField 4 | // 5 | // Created by Fabian Canas on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A token field with a label appropriate for use in a `Form` 11 | public struct FormTokenField : View where Data: RandomAccessCollection, Label: View { 12 | 13 | @Binding private var data: Data 14 | 15 | private var label: Label 16 | 17 | private var conversion: (Data.Element) -> String 18 | 19 | /// Creates a Token Field with a `ViewBuilder` for its `Form` label 20 | public init(@ViewBuilder _ label: () -> Label, _ data: Binding, _ tokenConversion: @escaping (Data.Element) -> String) { 21 | self.label = label() 22 | conversion = tokenConversion 23 | _data = data 24 | } 25 | 26 | public var body: some View { 27 | HStack { 28 | label 29 | TokenField($data, conversion) 30 | .alignmentGuide(.controlAlignment) { $0[.leading] } 31 | } 32 | .alignmentGuide(.leading) { $0[.controlAlignment] } 33 | } 34 | } 35 | 36 | public extension FormTokenField where Data.Element: StringProtocol { 37 | /// Creates a Token Field of `StringProtocol` tokens with a `ViewBuilder` for its `Form` label 38 | init(@ViewBuilder _ label: () -> Label, _ data: Binding) { 39 | self.label = label() 40 | conversion = { String($0) } 41 | _data = data 42 | } 43 | } 44 | 45 | public extension FormTokenField where Label == Text, Data.Element: StringProtocol, Data.Element: Identifiable { 46 | /// Creates a Token Field of `StringProtocol` tokens with a text label generated from a title string. 47 | init(title: S, _ data: Binding) where S: StringProtocol { 48 | label = Text(title) 49 | conversion = { String($0) } 50 | _data = data 51 | } 52 | } 53 | 54 | public extension FormTokenField where Label == Text { 55 | /// Creates a Token Field with a text label generated from a title string. 56 | init(title: S, _ data: Binding, _ tokenConversion: @escaping (Data.Element) -> String) where S: StringProtocol { 57 | label = Text(title) 58 | _data = data 59 | conversion = tokenConversion 60 | } 61 | } 62 | 63 | // Horizontal alignment enabling interaction with Form labels 64 | // https://developer.apple.com/forums/thread/126268 65 | extension HorizontalAlignment { 66 | private enum ControlAlignment: AlignmentID { 67 | static func defaultValue(in context: ViewDimensions) -> CGFloat { 68 | return context[HorizontalAlignment.center] 69 | } 70 | } 71 | internal static let controlAlignment = HorizontalAlignment(ControlAlignment.self) 72 | } 73 | -------------------------------------------------------------------------------- /Sources/TokenField/TokenField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenField.swift 3 | // TokenField 4 | // 5 | // Created by Fabian Canas on 7/6/21. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | import OSLog 11 | 12 | fileprivate let Log = Logger(subsystem: "TokenField", category: "tokenfield") 13 | 14 | public struct TokenField: View, NSViewRepresentable where Data: RandomAccessCollection { 15 | 16 | @Binding private var data: Data 17 | 18 | private var conversion: (Data.Element) -> String 19 | 20 | public init(_ data: Binding, _ tokenConversion: @escaping (Data.Element) -> String) { 21 | conversion = tokenConversion 22 | _data = data 23 | } 24 | 25 | public func makeCoordinator() -> Coordinator { 26 | Coordinator(self) 27 | } 28 | 29 | public final class Coordinator: NSObject, NSTokenFieldDelegate, ObservableObject where Data: RandomAccessCollection { 30 | 31 | var data: Binding? 32 | 33 | var parent: TokenField 34 | 35 | internal init(_ parent: TokenField) { 36 | self.parent = parent 37 | self.conversion = parent.conversion 38 | } 39 | 40 | private final class RepresentedToken where E: Identifiable { 41 | internal init(token: E, conversion: @escaping (E) -> String) { 42 | self.token = token 43 | self.conversion = conversion 44 | } 45 | var token: E 46 | var conversion: (E) -> String 47 | } 48 | 49 | var conversion: ((Data.Element) -> String)! = nil 50 | 51 | public func tokenField(_ tokenField: NSTokenField, displayStringForRepresentedObject representedObject: Any) -> String? { 52 | return representedObject as? String 53 | } 54 | 55 | public func tokenField(_ tokenField: NSTokenField, hasMenuForRepresentedObject representedObject: Any) -> Bool { 56 | return false 57 | } 58 | 59 | public func tokenField(_ tokenField: NSTokenField, styleForRepresentedObject representedObject: Any) -> NSTokenField.TokenStyle { 60 | return .rounded 61 | } 62 | 63 | public func tokenField(_ tokenField: NSTokenField, shouldAdd tokens: [Any], at index: Int) -> [Any] { 64 | guard let newTokens = tokens as? [AnyHashable] else { 65 | Log.debug("New tokens are not hashable") 66 | return tokens 67 | } 68 | guard let existingTokens = tokenField.objectValue as? [AnyHashable] else { 69 | Log.debug("Existing tokens are not hashable") 70 | return tokens 71 | } 72 | Log.debug("candidate: \(newTokens)") 73 | Log.debug("existing: \(existingTokens)") 74 | var set = Set() 75 | 76 | return newTokens.filter { t in 77 | defer {set.insert(t)} 78 | return !set.contains(t) 79 | } 80 | } 81 | 82 | public func controlTextDidChange(_ obj: Notification) { 83 | guard let tf = obj.object as? NSTokenField else { 84 | Log.debug("Control text did change, but object not a token field") 85 | return 86 | } 87 | guard let data = tf.objectValue as? Data else { 88 | Log.debug("Control text did change, but object value data unexpected type: \(type(of: tf.objectValue))") 89 | return 90 | } 91 | self.data?.wrappedValue = data 92 | } 93 | } 94 | 95 | public func makeNSView(context: Context) -> NSTokenField { 96 | let tf = NSTokenField() 97 | tf.autoresizingMask = [.width, .height] 98 | tf.tokenStyle = .plainSquared 99 | tf.setContentHuggingPriority(.defaultLow, for: .vertical) 100 | 101 | tf.objectValue = data 102 | let cell = tf.cell as? NSTokenFieldCell 103 | cell?.setCellAttribute(.cellIsBordered, to: 1) 104 | cell?.tokenStyle = .rounded 105 | context.coordinator.data = _data 106 | context.coordinator.conversion = self.conversion 107 | tf.delegate = context.coordinator 108 | return tf 109 | } 110 | 111 | public func updateNSView(_ nsView: NSTokenField, context: Context) { 112 | if let b = nsView.superview?.bounds { 113 | nsView.frame = b 114 | } 115 | } 116 | } 117 | 118 | extension TokenField where Data.Element == String { 119 | public init(_ data: Binding) { 120 | conversion = {$0} 121 | _data = data 122 | } 123 | } 124 | --------------------------------------------------------------------------------