├── .github └── FUNDING.yml ├── .gitignore ├── .spi.yml ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── Example │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── background.colorset │ │ └── Contents.json │ └── textColor.colorset │ │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── InsertTextKeyboardTool.swift │ ├── MainView.swift │ ├── MainViewController.swift │ └── SceneDelegate.swift ├── LICENSE ├── Package.swift ├── README.md └── Sources └── KeyboardToolbar ├── Assets.xcassets ├── Contents.json ├── keyboard_tool_button_primary.colorset │ └── Contents.json ├── keyboard_tool_button_secondary.colorset │ └── Contents.json ├── keyboard_tool_foreground.colorset │ └── Contents.json └── keyboard_tool_foreground_highlighted.colorset │ └── Contents.json ├── BlockKeyboardTool.swift ├── Documentation.docc ├── Documentation.md ├── Extensions │ ├── BlockKeyboardTool.md │ ├── KeyboardTool.md │ ├── KeyboardToolDisplayRepresentation.md │ └── KeyboardToolbarView.md └── Resources │ ├── keyboard.png │ └── keyboard~dark.png ├── Helpers ├── Device.swift ├── InputToolMargin.swift ├── UIColor+Helpers.swift ├── UIImage+Helpers.swift └── UIView+Helpers.swift ├── KeyboardTool.swift ├── KeyboardToolDisplayRepresentation.swift ├── KeyboardToolGroup.swift ├── KeyboardToolGroupItem.swift ├── KeyboardToolbarView.swift └── Views ├── KeyboardToolButton.swift ├── KeyboardToolButtonBackgroundView.swift ├── KeyboardToolContentSize.swift ├── KeyboardToolPickerBackgroundView.swift ├── KeyboardToolPickerFrameCalculator.swift ├── KeyboardToolPickerView.swift └── KeyboardToolView.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: simonbs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swiftpm 3 | .build 4 | xcuserdata 5 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | documentation_targets: [KeyboardToolbar] -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 728F01032868D06700A69B14 /* KeyboardToolbar in Frameworks */ = {isa = PBXBuildFile; productRef = 728F01022868D06700A69B14 /* KeyboardToolbar */; }; 11 | 72D6B17628678687005C6870 /* InsertTextKeyboardTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D6B17528678687005C6870 /* InsertTextKeyboardTool.swift */; }; 12 | 72E507A9286770CC00B77A1C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72E507A8286770CC00B77A1C /* AppDelegate.swift */; }; 13 | 72E507AB286770CC00B77A1C /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72E507AA286770CC00B77A1C /* SceneDelegate.swift */; }; 14 | 72E507AD286770CC00B77A1C /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72E507AC286770CC00B77A1C /* MainViewController.swift */; }; 15 | 72E507B2286770CD00B77A1C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72E507B1286770CD00B77A1C /* Assets.xcassets */; }; 16 | 72E507B5286770CD00B77A1C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 72E507B3286770CD00B77A1C /* LaunchScreen.storyboard */; }; 17 | 72E507BD2867711A00B77A1C /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72E507BC2867711A00B77A1C /* MainView.swift */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXFileReference section */ 21 | 72D6B17528678687005C6870 /* InsertTextKeyboardTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertTextKeyboardTool.swift; sourceTree = ""; }; 22 | 72E507A5286770CC00B77A1C /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 23 | 72E507A8286770CC00B77A1C /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 24 | 72E507AA286770CC00B77A1C /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 25 | 72E507AC286770CC00B77A1C /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; 26 | 72E507B1286770CD00B77A1C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 27 | 72E507B4286770CD00B77A1C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 28 | 72E507B6286770CD00B77A1C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 29 | 72E507BC2867711A00B77A1C /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 30 | 72E507BF2867719500B77A1C /* KeyboardTools */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = KeyboardTools; path = ..; sourceTree = ""; }; 31 | /* End PBXFileReference section */ 32 | 33 | /* Begin PBXFrameworksBuildPhase section */ 34 | 72E507A2286770CC00B77A1C /* Frameworks */ = { 35 | isa = PBXFrameworksBuildPhase; 36 | buildActionMask = 2147483647; 37 | files = ( 38 | 728F01032868D06700A69B14 /* KeyboardToolbar in Frameworks */, 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | 72A73215286777CC0002BA71 /* Frameworks */ = { 46 | isa = PBXGroup; 47 | children = ( 48 | ); 49 | name = Frameworks; 50 | sourceTree = ""; 51 | }; 52 | 72E5079C286770CC00B77A1C = { 53 | isa = PBXGroup; 54 | children = ( 55 | 72E507BE2867719500B77A1C /* Packages */, 56 | 72E507A7286770CC00B77A1C /* Example */, 57 | 72E507A6286770CC00B77A1C /* Products */, 58 | 72A73215286777CC0002BA71 /* Frameworks */, 59 | ); 60 | sourceTree = ""; 61 | }; 62 | 72E507A6286770CC00B77A1C /* Products */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | 72E507A5286770CC00B77A1C /* Example.app */, 66 | ); 67 | name = Products; 68 | sourceTree = ""; 69 | }; 70 | 72E507A7286770CC00B77A1C /* Example */ = { 71 | isa = PBXGroup; 72 | children = ( 73 | 72E507B6286770CD00B77A1C /* Info.plist */, 74 | 72E507A8286770CC00B77A1C /* AppDelegate.swift */, 75 | 72D6B17528678687005C6870 /* InsertTextKeyboardTool.swift */, 76 | 72E507BC2867711A00B77A1C /* MainView.swift */, 77 | 72E507AC286770CC00B77A1C /* MainViewController.swift */, 78 | 72E507AA286770CC00B77A1C /* SceneDelegate.swift */, 79 | 72E507B1286770CD00B77A1C /* Assets.xcassets */, 80 | 72E507B3286770CD00B77A1C /* LaunchScreen.storyboard */, 81 | ); 82 | path = Example; 83 | sourceTree = ""; 84 | }; 85 | 72E507BE2867719500B77A1C /* Packages */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | 72E507BF2867719500B77A1C /* KeyboardTools */, 89 | ); 90 | name = Packages; 91 | sourceTree = ""; 92 | }; 93 | /* End PBXGroup section */ 94 | 95 | /* Begin PBXNativeTarget section */ 96 | 72E507A4286770CC00B77A1C /* Example */ = { 97 | isa = PBXNativeTarget; 98 | buildConfigurationList = 72E507B9286770CD00B77A1C /* Build configuration list for PBXNativeTarget "Example" */; 99 | buildPhases = ( 100 | 72E507A1286770CC00B77A1C /* Sources */, 101 | 72E507A2286770CC00B77A1C /* Frameworks */, 102 | 72E507A3286770CC00B77A1C /* Resources */, 103 | ); 104 | buildRules = ( 105 | ); 106 | dependencies = ( 107 | ); 108 | name = Example; 109 | packageProductDependencies = ( 110 | 728F01022868D06700A69B14 /* KeyboardToolbar */, 111 | ); 112 | productName = Example; 113 | productReference = 72E507A5286770CC00B77A1C /* Example.app */; 114 | productType = "com.apple.product-type.application"; 115 | }; 116 | /* End PBXNativeTarget section */ 117 | 118 | /* Begin PBXProject section */ 119 | 72E5079D286770CC00B77A1C /* Project object */ = { 120 | isa = PBXProject; 121 | attributes = { 122 | BuildIndependentTargetsInParallel = 1; 123 | LastSwiftUpdateCheck = 1400; 124 | LastUpgradeCheck = 1400; 125 | TargetAttributes = { 126 | 72E507A4286770CC00B77A1C = { 127 | CreatedOnToolsVersion = 14.0; 128 | }; 129 | }; 130 | }; 131 | buildConfigurationList = 72E507A0286770CC00B77A1C /* Build configuration list for PBXProject "Example" */; 132 | compatibilityVersion = "Xcode 14.0"; 133 | developmentRegion = en; 134 | hasScannedForEncodings = 0; 135 | knownRegions = ( 136 | en, 137 | Base, 138 | ); 139 | mainGroup = 72E5079C286770CC00B77A1C; 140 | productRefGroup = 72E507A6286770CC00B77A1C /* Products */; 141 | projectDirPath = ""; 142 | projectRoot = ""; 143 | targets = ( 144 | 72E507A4286770CC00B77A1C /* Example */, 145 | ); 146 | }; 147 | /* End PBXProject section */ 148 | 149 | /* Begin PBXResourcesBuildPhase section */ 150 | 72E507A3286770CC00B77A1C /* Resources */ = { 151 | isa = PBXResourcesBuildPhase; 152 | buildActionMask = 2147483647; 153 | files = ( 154 | 72E507B5286770CD00B77A1C /* LaunchScreen.storyboard in Resources */, 155 | 72E507B2286770CD00B77A1C /* Assets.xcassets in Resources */, 156 | ); 157 | runOnlyForDeploymentPostprocessing = 0; 158 | }; 159 | /* End PBXResourcesBuildPhase section */ 160 | 161 | /* Begin PBXSourcesBuildPhase section */ 162 | 72E507A1286770CC00B77A1C /* Sources */ = { 163 | isa = PBXSourcesBuildPhase; 164 | buildActionMask = 2147483647; 165 | files = ( 166 | 72E507AD286770CC00B77A1C /* MainViewController.swift in Sources */, 167 | 72D6B17628678687005C6870 /* InsertTextKeyboardTool.swift in Sources */, 168 | 72E507BD2867711A00B77A1C /* MainView.swift in Sources */, 169 | 72E507A9286770CC00B77A1C /* AppDelegate.swift in Sources */, 170 | 72E507AB286770CC00B77A1C /* SceneDelegate.swift in Sources */, 171 | ); 172 | runOnlyForDeploymentPostprocessing = 0; 173 | }; 174 | /* End PBXSourcesBuildPhase section */ 175 | 176 | /* Begin PBXVariantGroup section */ 177 | 72E507B3286770CD00B77A1C /* LaunchScreen.storyboard */ = { 178 | isa = PBXVariantGroup; 179 | children = ( 180 | 72E507B4286770CD00B77A1C /* Base */, 181 | ); 182 | name = LaunchScreen.storyboard; 183 | sourceTree = ""; 184 | }; 185 | /* End PBXVariantGroup section */ 186 | 187 | /* Begin XCBuildConfiguration section */ 188 | 72E507B7286770CD00B77A1C /* Debug */ = { 189 | isa = XCBuildConfiguration; 190 | buildSettings = { 191 | ALWAYS_SEARCH_USER_PATHS = NO; 192 | CLANG_ANALYZER_NONNULL = YES; 193 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 194 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 195 | CLANG_ENABLE_MODULES = YES; 196 | CLANG_ENABLE_OBJC_ARC = YES; 197 | CLANG_ENABLE_OBJC_WEAK = YES; 198 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 199 | CLANG_WARN_BOOL_CONVERSION = YES; 200 | CLANG_WARN_COMMA = YES; 201 | CLANG_WARN_CONSTANT_CONVERSION = YES; 202 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 203 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 204 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 205 | CLANG_WARN_EMPTY_BODY = YES; 206 | CLANG_WARN_ENUM_CONVERSION = YES; 207 | CLANG_WARN_INFINITE_RECURSION = YES; 208 | CLANG_WARN_INT_CONVERSION = YES; 209 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 210 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 211 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 212 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 213 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 214 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 215 | CLANG_WARN_STRICT_PROTOTYPES = YES; 216 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 217 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 218 | CLANG_WARN_UNREACHABLE_CODE = YES; 219 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 220 | COPY_PHASE_STRIP = NO; 221 | DEBUG_INFORMATION_FORMAT = dwarf; 222 | ENABLE_STRICT_OBJC_MSGSEND = YES; 223 | ENABLE_TESTABILITY = YES; 224 | GCC_C_LANGUAGE_STANDARD = gnu11; 225 | GCC_DYNAMIC_NO_PIC = NO; 226 | GCC_NO_COMMON_BLOCKS = YES; 227 | GCC_OPTIMIZATION_LEVEL = 0; 228 | GCC_PREPROCESSOR_DEFINITIONS = ( 229 | "DEBUG=1", 230 | "$(inherited)", 231 | ); 232 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 233 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 234 | GCC_WARN_UNDECLARED_SELECTOR = YES; 235 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 236 | GCC_WARN_UNUSED_FUNCTION = YES; 237 | GCC_WARN_UNUSED_VARIABLE = YES; 238 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 239 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 240 | MTL_FAST_MATH = YES; 241 | ONLY_ACTIVE_ARCH = YES; 242 | SDKROOT = iphoneos; 243 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 244 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 245 | }; 246 | name = Debug; 247 | }; 248 | 72E507B8286770CD00B77A1C /* Release */ = { 249 | isa = XCBuildConfiguration; 250 | buildSettings = { 251 | ALWAYS_SEARCH_USER_PATHS = NO; 252 | CLANG_ANALYZER_NONNULL = YES; 253 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 254 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 255 | CLANG_ENABLE_MODULES = YES; 256 | CLANG_ENABLE_OBJC_ARC = YES; 257 | CLANG_ENABLE_OBJC_WEAK = YES; 258 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 259 | CLANG_WARN_BOOL_CONVERSION = YES; 260 | CLANG_WARN_COMMA = YES; 261 | CLANG_WARN_CONSTANT_CONVERSION = YES; 262 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 263 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 264 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 265 | CLANG_WARN_EMPTY_BODY = YES; 266 | CLANG_WARN_ENUM_CONVERSION = YES; 267 | CLANG_WARN_INFINITE_RECURSION = YES; 268 | CLANG_WARN_INT_CONVERSION = YES; 269 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 270 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 271 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 272 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 273 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 274 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 275 | CLANG_WARN_STRICT_PROTOTYPES = YES; 276 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 277 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 278 | CLANG_WARN_UNREACHABLE_CODE = YES; 279 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 280 | COPY_PHASE_STRIP = NO; 281 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 282 | ENABLE_NS_ASSERTIONS = NO; 283 | ENABLE_STRICT_OBJC_MSGSEND = YES; 284 | GCC_C_LANGUAGE_STANDARD = gnu11; 285 | GCC_NO_COMMON_BLOCKS = YES; 286 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 287 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 288 | GCC_WARN_UNDECLARED_SELECTOR = YES; 289 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 290 | GCC_WARN_UNUSED_FUNCTION = YES; 291 | GCC_WARN_UNUSED_VARIABLE = YES; 292 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 293 | MTL_ENABLE_DEBUG_INFO = NO; 294 | MTL_FAST_MATH = YES; 295 | SDKROOT = iphoneos; 296 | SWIFT_COMPILATION_MODE = wholemodule; 297 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 298 | VALIDATE_PRODUCT = YES; 299 | }; 300 | name = Release; 301 | }; 302 | 72E507BA286770CD00B77A1C /* Debug */ = { 303 | isa = XCBuildConfiguration; 304 | buildSettings = { 305 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 306 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 307 | CODE_SIGN_STYLE = Automatic; 308 | CURRENT_PROJECT_VERSION = 1; 309 | DEVELOPMENT_TEAM = 8NQFWJHC63; 310 | GENERATE_INFOPLIST_FILE = YES; 311 | INFOPLIST_FILE = Example/Info.plist; 312 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 313 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 314 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 315 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 316 | LD_RUNPATH_SEARCH_PATHS = ( 317 | "$(inherited)", 318 | "@executable_path/Frameworks", 319 | ); 320 | MARKETING_VERSION = 1.0; 321 | PRODUCT_BUNDLE_IDENTIFIER = dk.simonbs.Example; 322 | PRODUCT_NAME = "$(TARGET_NAME)"; 323 | SWIFT_EMIT_LOC_STRINGS = YES; 324 | SWIFT_VERSION = 5.0; 325 | TARGETED_DEVICE_FAMILY = "1,2"; 326 | }; 327 | name = Debug; 328 | }; 329 | 72E507BB286770CD00B77A1C /* Release */ = { 330 | isa = XCBuildConfiguration; 331 | buildSettings = { 332 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 333 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 334 | CODE_SIGN_STYLE = Automatic; 335 | CURRENT_PROJECT_VERSION = 1; 336 | DEVELOPMENT_TEAM = 8NQFWJHC63; 337 | GENERATE_INFOPLIST_FILE = YES; 338 | INFOPLIST_FILE = Example/Info.plist; 339 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 340 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 341 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 342 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 343 | LD_RUNPATH_SEARCH_PATHS = ( 344 | "$(inherited)", 345 | "@executable_path/Frameworks", 346 | ); 347 | MARKETING_VERSION = 1.0; 348 | PRODUCT_BUNDLE_IDENTIFIER = dk.simonbs.Example; 349 | PRODUCT_NAME = "$(TARGET_NAME)"; 350 | SWIFT_EMIT_LOC_STRINGS = YES; 351 | SWIFT_VERSION = 5.0; 352 | TARGETED_DEVICE_FAMILY = "1,2"; 353 | }; 354 | name = Release; 355 | }; 356 | /* End XCBuildConfiguration section */ 357 | 358 | /* Begin XCConfigurationList section */ 359 | 72E507A0286770CC00B77A1C /* Build configuration list for PBXProject "Example" */ = { 360 | isa = XCConfigurationList; 361 | buildConfigurations = ( 362 | 72E507B7286770CD00B77A1C /* Debug */, 363 | 72E507B8286770CD00B77A1C /* Release */, 364 | ); 365 | defaultConfigurationIsVisible = 0; 366 | defaultConfigurationName = Release; 367 | }; 368 | 72E507B9286770CD00B77A1C /* Build configuration list for PBXNativeTarget "Example" */ = { 369 | isa = XCConfigurationList; 370 | buildConfigurations = ( 371 | 72E507BA286770CD00B77A1C /* Debug */, 372 | 72E507BB286770CD00B77A1C /* Release */, 373 | ); 374 | defaultConfigurationIsVisible = 0; 375 | defaultConfigurationName = Release; 376 | }; 377 | /* End XCConfigurationList section */ 378 | 379 | /* Begin XCSwiftPackageProductDependency section */ 380 | 728F01022868D06700A69B14 /* KeyboardToolbar */ = { 381 | isa = XCSwiftPackageProductDependency; 382 | productName = KeyboardToolbar; 383 | }; 384 | /* End XCSwiftPackageProductDependency section */ 385 | }; 386 | rootObject = 72E5079D286770CC00B77A1C /* Project object */; 387 | } 388 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @main 4 | final class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 6 | return true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Example/Example/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 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x36", 27 | "green" : "0x2A", 28 | "red" : "0x28" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/textColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xF2", 27 | "green" : "0xF8", 28 | "red" : "0xF8" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Example/Example/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Example/Example/InsertTextKeyboardTool.swift: -------------------------------------------------------------------------------- 1 | import KeyboardToolbar 2 | import UIKit 3 | 4 | struct InsertTextKeyboardTool: KeyboardTool { 5 | let displayRepresentation: KeyboardToolDisplayRepresentation 6 | 7 | private let text: String 8 | private weak var textView: UITextInput? 9 | 10 | init(text: String, textView: UITextInput) { 11 | self.displayRepresentation = .text(text) 12 | self.text = text 13 | self.textView = textView 14 | } 15 | 16 | func performAction() { 17 | textView?.insertText(text) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Example/Example/MainView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class MainView: UIView { 4 | let textView: UITextView = { 5 | let this = UITextView() 6 | this.translatesAutoresizingMaskIntoConstraints = false 7 | this.font = .preferredFont(forTextStyle: .body) 8 | this.textColor = UIColor(named: "textColor") 9 | this.isFindInteractionEnabled = true 10 | this.backgroundColor = UIColor(named: "background") 11 | return this 12 | }() 13 | 14 | init() { 15 | super.init(frame: .zero) 16 | setupView() 17 | setupLayout() 18 | } 19 | 20 | required init?(coder aDecoder: NSCoder) { 21 | fatalError("init(coder:) has not been implemented") 22 | } 23 | 24 | private func setupView() { 25 | backgroundColor = UIColor(named: "background") 26 | addSubview(textView) 27 | } 28 | 29 | private func setupLayout() { 30 | NSLayoutConstraint.activate([ 31 | textView.leadingAnchor.constraint(equalTo: leadingAnchor), 32 | textView.trailingAnchor.constraint(equalTo: trailingAnchor), 33 | textView.topAnchor.constraint(equalTo: topAnchor), 34 | textView.bottomAnchor.constraint(equalTo: bottomAnchor) 35 | ]) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/Example/MainViewController.swift: -------------------------------------------------------------------------------- 1 | import KeyboardToolbar 2 | import UIKit 3 | 4 | final class MainViewController: UIViewController { 5 | private let contentView = MainView() 6 | private let keyboardToolbarView = KeyboardToolbarView() 7 | private var textView: UITextView { 8 | return contentView.textView 9 | } 10 | 11 | override func loadView() { 12 | view = contentView 13 | } 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = "Text View" 18 | textView.delegate = self 19 | textView.inputAccessoryView = keyboardToolbarView 20 | setupKeyboardTools() 21 | } 22 | 23 | override func viewDidAppear(_ animated: Bool) { 24 | super.viewDidAppear(animated) 25 | textView.becomeFirstResponder() 26 | } 27 | } 28 | 29 | private extension MainViewController { 30 | private func setupKeyboardTools() { 31 | let canUndo = textView.undoManager?.canUndo ?? false 32 | let canRedo = textView.undoManager?.canRedo ?? false 33 | keyboardToolbarView.groups = [ 34 | KeyboardToolGroup(items: [ 35 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "arrow.uturn.backward") { [weak self] in 36 | self?.textView.undoManager?.undo() 37 | self?.setupKeyboardTools() 38 | }, isEnabled: canUndo), 39 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "arrow.uturn.forward") { [weak self] in 40 | self?.textView.undoManager?.redo() 41 | self?.setupKeyboardTools() 42 | }, isEnabled: canRedo) 43 | ]), 44 | KeyboardToolGroup(items: [ 45 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: "(", textView: textView), tools: [ 46 | InsertTextKeyboardTool(text: "(", textView: textView), 47 | InsertTextKeyboardTool(text: "{", textView: textView), 48 | InsertTextKeyboardTool(text: "[", textView: textView), 49 | InsertTextKeyboardTool(text: "]", textView: textView), 50 | InsertTextKeyboardTool(text: "}", textView: textView), 51 | InsertTextKeyboardTool(text: ")", textView: textView) 52 | ]), 53 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: ".", textView: textView), tools: [ 54 | InsertTextKeyboardTool(text: ".", textView: textView), 55 | InsertTextKeyboardTool(text: ",", textView: textView), 56 | InsertTextKeyboardTool(text: ";", textView: textView), 57 | InsertTextKeyboardTool(text: "!", textView: textView), 58 | InsertTextKeyboardTool(text: "&", textView: textView), 59 | InsertTextKeyboardTool(text: "|", textView: textView) 60 | ]), 61 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: "=", textView: textView), tools: [ 62 | InsertTextKeyboardTool(text: "=", textView: textView), 63 | InsertTextKeyboardTool(text: "+", textView: textView), 64 | InsertTextKeyboardTool(text: "-", textView: textView), 65 | InsertTextKeyboardTool(text: "/", textView: textView), 66 | InsertTextKeyboardTool(text: "*", textView: textView), 67 | InsertTextKeyboardTool(text: "<", textView: textView), 68 | InsertTextKeyboardTool(text: ">", textView: textView) 69 | ]), 70 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: "#", textView: textView), tools: [ 71 | InsertTextKeyboardTool(text: "#", textView: textView), 72 | InsertTextKeyboardTool(text: "\"", textView: textView), 73 | InsertTextKeyboardTool(text: "'", textView: textView), 74 | InsertTextKeyboardTool(text: "$", textView: textView), 75 | InsertTextKeyboardTool(text: "\\", textView: textView), 76 | InsertTextKeyboardTool(text: "@", textView: textView), 77 | InsertTextKeyboardTool(text: "%", textView: textView), 78 | InsertTextKeyboardTool(text: "~", textView: textView) 79 | ]) 80 | ]), 81 | KeyboardToolGroup(items: [ 82 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "magnifyingglass") { [weak self] in 83 | self?.textView.findInteraction?.presentFindNavigator(showingReplace: false) 84 | }), 85 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "keyboard.chevron.compact.down") { [weak self] in 86 | self?.textView.resignFirstResponder() 87 | }) 88 | ]) 89 | ] 90 | } 91 | } 92 | 93 | extension MainViewController: UITextViewDelegate { 94 | func textViewDidChange(_ textView: UITextView) { 95 | setupKeyboardTools() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Example/Example/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | var window: UIWindow? 5 | 6 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 7 | let windowScene = scene as! UIWindowScene 8 | window = UIWindow(windowScene: windowScene) 9 | window?.rootViewController = UINavigationController(rootViewController: MainViewController()) 10 | window?.makeKeyAndVisible() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Simon Støvring 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 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: "KeyboardToolbar", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | .library(name: "KeyboardToolbar", targets: ["KeyboardToolbar"]), 11 | ], 12 | targets: [ 13 | .target(name: "KeyboardToolbar", resources: [.process("Assets.xcassets")]) 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KeyboardToolbar 2 | 3 | ![](Sources/KeyboardToolbar/Documentation.docc/Resources/keyboard.png#gh-light-mode-only) 4 | ![](Sources/KeyboardToolbar/Documentation.docc/Resources/keyboard~dark.png#gh-dark-mode-only) 5 | 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsimonbs%2FKeyboardToolbar%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/simonbs/KeyboardToolbar) 7 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsimonbs%2FKeyboardToolbar%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/simonbs/KeyboardToolbar) 8 | [![](https://img.shields.io/badge/twitter-@simonbs-blue.svg?style=flat)]([https://swiftpackageindex.com/simonbs/Runestone](https://twitter.com/simonbs)) 9 | 10 | ## 👀 Overview 11 | 12 | Use KeyboardToolbar to add tools as an input accessory view to a UITextField, UITextView, or any other view conforming to UITextInput. 13 | 14 | KeyboardToolbar creates buttons with an iOS-like appearance and behavior. 15 | 16 | ## 📖 Documentation 17 | 18 | The public interface is documented in the Swift files and can be found in [KeyboardToolbar/Sources/KeyboardToolbar](https://github.com/simonbs/KeyboardToolbar/tree/main/Sources/KeyboardToolbar). You can also [read the documention on Swift Package Index](https://swiftpackageindex.com/simonbs/KeyboardToolbar). 19 | 20 | Lastly, you can also build the documentation yourself by opening the Swift package in Xcode and selecting Product > Build Documentation in the menu bar. 21 | 22 | ## 📦 Adding the Package 23 | 24 | KeyboardToolbar is distributed using the [Swift Package Manager](https://www.swift.org/package-manager/). Install it in a project by adding it as a dependency in your Package.swift manifest or through “Package Dependencies” in project settings. 25 | 26 | ```swift 27 | let package = Package( 28 | dependencies: [ 29 | .package(url: "git@github.com:simonbs/KeyboardToolbar.git", from: "0.1.0") 30 | ] 31 | ) 32 | ``` 33 | 34 | ## 🚀 Getting Started 35 | 36 | The best way to understand how KeyboardToolbar is integrated into your project is by having a look at the [Example project](Example/Example) in this repository. 37 | 38 | At a high level there are two steps required to setting up the keyboard toolbar. 39 | 40 | 1. Create an instance of [KeyboardToolbarView](https://github.com/simonbs/KeyboardToolbar/blob/main/Sources/KeyboardToolbar/KeyboardToolbarView.swift) and assign it to [inputAccessoryView](https://developer.apple.com/documentation/uikit/uitextfield/1619627-inputaccessoryview) on a UITextField, UITextView, or any other view that conforms to the UITextInput protocol. 41 | 2. Assign an array of [KeyboardToolGroup](https://github.com/simonbs/KeyboardToolbar/blob/main/Sources/KeyboardToolbar/KeyboardToolGroup.swift) items to the `groups` property on your instance of KeyboardToolbarView. 42 | 43 | The below code snippet shows how the two steps can be performed. 44 | 45 | ```swift 46 | /// Create our instance of KeyboardToolbarView and pass it to an instance of UITextView. 47 | let keyboardToolbarView = KeyboardToolbarView() 48 | textView.inputAccessoryView = keyboardToolbarView 49 | // Setup our tool groups. 50 | let canUndo = textView.undoManager?.canUndo ?? false 51 | let canRedo = textView.undoManager?.canRedo ?? false 52 | keyboardToolbarView.groups = [ 53 | // Tools for undoing and redoing text in the text view. 54 | KeyboardToolGroup(items: [ 55 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "arrow.uturn.backward") { [weak self] in 56 | self?.textView.undoManager?.undo() 57 | self?.setupKeyboardTools() 58 | }, isEnabled: canUndo), 59 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "arrow.uturn.forward") { [weak self] in 60 | self?.textView.undoManager?.redo() 61 | self?.setupKeyboardTools() 62 | }, isEnabled: canRedo) 63 | ]), 64 | // Tools for inserting characters into our text view. 65 | KeyboardToolGroup(items: [ 66 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: "(", textView: textView), tools: [ 67 | InsertTextKeyboardTool(text: "(", textView: textView), 68 | InsertTextKeyboardTool(text: "{", textView: textView), 69 | InsertTextKeyboardTool(text: "[", textView: textView), 70 | InsertTextKeyboardTool(text: "]", textView: textView), 71 | InsertTextKeyboardTool(text: "}", textView: textView), 72 | InsertTextKeyboardTool(text: ")", textView: textView) 73 | ]), 74 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: ".", textView: textView), tools: [ 75 | InsertTextKeyboardTool(text: ".", textView: textView), 76 | InsertTextKeyboardTool(text: ",", textView: textView), 77 | InsertTextKeyboardTool(text: ";", textView: textView), 78 | InsertTextKeyboardTool(text: "!", textView: textView), 79 | InsertTextKeyboardTool(text: "&", textView: textView), 80 | InsertTextKeyboardTool(text: "|", textView: textView) 81 | ]), 82 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: "=", textView: textView), tools: [ 83 | InsertTextKeyboardTool(text: "=", textView: textView), 84 | InsertTextKeyboardTool(text: "+", textView: textView), 85 | InsertTextKeyboardTool(text: "-", textView: textView), 86 | InsertTextKeyboardTool(text: "/", textView: textView), 87 | InsertTextKeyboardTool(text: "*", textView: textView), 88 | InsertTextKeyboardTool(text: "<", textView: textView), 89 | InsertTextKeyboardTool(text: ">", textView: textView) 90 | ]), 91 | KeyboardToolGroupItem(representativeTool: InsertTextKeyboardTool(text: "#", textView: textView), tools: [ 92 | InsertTextKeyboardTool(text: "#", textView: textView), 93 | InsertTextKeyboardTool(text: "\"", textView: textView), 94 | InsertTextKeyboardTool(text: "'", textView: textView), 95 | InsertTextKeyboardTool(text: "$", textView: textView), 96 | InsertTextKeyboardTool(text: "\\", textView: textView), 97 | InsertTextKeyboardTool(text: "@", textView: textView), 98 | InsertTextKeyboardTool(text: "%", textView: textView), 99 | InsertTextKeyboardTool(text: "~", textView: textView) 100 | ]) 101 | ]), 102 | KeyboardToolGroup(items: [ 103 | // Tool to present the find navigator. 104 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "magnifyingglass") { [weak self] in 105 | self?.textView.findInteraction?.presentFindNavigator(showingReplace: false) 106 | }), 107 | // Tool to dismiss the keyboard. 108 | KeyboardToolGroupItem(style: .secondary, representativeTool: BlockKeyboardTool(symbolName: "keyboard.chevron.compact.down") { [weak self] in 109 | self?.textView.resignFirstResponder() 110 | }) 111 | ]) 112 | ] 113 | ``` 114 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Assets.xcassets/keyboard_tool_button_primary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.300", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Assets.xcassets/keyboard_tool_button_secondary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.757", 9 | "green" : "0.722", 10 | "red" : "0.706" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.130", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Assets.xcassets/keyboard_tool_foreground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Assets.xcassets/keyboard_tool_foreground_highlighted.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/BlockKeyboardTool.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A tool that performs a block-based action when selected. 4 | /// 5 | /// This isn implementation of ``KeyboardTool`` that performs a block when the tool is selected in the toolbar. 6 | /// 7 | /// Consider using this for simple actions only and implement objects conforming to ``KeyboardTool`` for complex actions. 8 | public struct BlockKeyboardTool: KeyboardTool { 9 | /// Specifies how the tool should be displayed in the toolbar. 10 | public let displayRepresentation: KeyboardToolDisplayRepresentation 11 | /// The block to be called when the tool is selected. 12 | public let actionHandler: () -> Void 13 | 14 | /// Initializes a keyboard tool. 15 | /// - Parameters: 16 | /// - displayRepresentation: Specifies how the tool should be displayed in the toolbar. 17 | /// - actionHandler: The block to be called when the tool is selected. 18 | public init(displayRepresentation: KeyboardToolDisplayRepresentation, action actionHandler: @escaping () -> Void) { 19 | self.displayRepresentation = displayRepresentation 20 | self.actionHandler = actionHandler 21 | } 22 | 23 | /// Initializes a tool that displays a text. 24 | /// - Parameters: 25 | /// - text: The string to use when displaying the tool. 26 | /// - offset: Offset to be applied to the text when shown in a button. Defaults to (0, 0). 27 | /// - actionHandler: The block to be called when the tool is selected. 28 | public init(text: String, offset: CGPoint = .zero, action actionHandler: @escaping () -> Void) { 29 | let displayRepresentation: KeyboardToolDisplayRepresentation = .text(text, offset: offset) 30 | self.init(displayRepresentation: displayRepresentation, action: actionHandler) 31 | } 32 | 33 | /// Initializes a tool that displays an image. 34 | /// - Parameters: 35 | /// - smallImage: The image to use when displaying the tool. 36 | /// - largeImage: The image to use when momentarily displaying the tool picker. 37 | /// - actionHandler: The block to be called when the tool is selected. 38 | public init(smallImage: UIImage, largeImage: UIImage, action actionHandler: @escaping () -> Void) { 39 | let displayRepresentation: KeyboardToolDisplayRepresentation = .image(small: smallImage, large: largeImage) 40 | self.init(displayRepresentation: displayRepresentation, action: actionHandler) 41 | } 42 | 43 | /// Initializes a tool that displays a SF Symbol. 44 | /// - Parameters: 45 | /// - symbolName: The name of the symbol to use when displaying the tool. 46 | /// - pointSize: The point size of the symbol. Defaults to 14. 47 | /// - actionHandler: The block to be called when the tool is selected. 48 | public init(symbolName: String, pointSize: CGFloat = 14, action actionHandler: @escaping () -> Void) { 49 | let displayRepresentation: KeyboardToolDisplayRepresentation = .symbol(named: symbolName, pointSize: pointSize) 50 | self.init(displayRepresentation: displayRepresentation, action: actionHandler) 51 | } 52 | 53 | /// Called when selecting the tool in the toolbar. 54 | /// 55 | /// Calling this function will call ``actionHandler``. 56 | public func performAction() { 57 | actionHandler() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``KeyboardToolbar`` 2 | 3 | Add tools above your keyboard with iOS-like keyboard buttons. 4 | 5 | ## Overview 6 | 7 | Use KeyboardToolbar to add tools as an input accessory view to a UITextField, UITextView, or any other view conforming to UITextInput. 8 | 9 | KeyboardToolbar creates buttons with an iOS-like appearance and behavior. 10 | 11 | ![Preview of a toolbar.](keyboard.png) 12 | 13 | ## Topics 14 | 15 | ### Toolbar 16 | 17 | - ``KeyboardToolbarView`` 18 | 19 | ### Creating Tools 20 | 21 | - ``KeyboardTool`` 22 | - ``KeyboardToolDisplayRepresentation`` 23 | - ``BlockKeyboardTool`` 24 | - ``KeyboardToolGroup`` 25 | - ``KeyboardToolGroupItem`` 26 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Documentation.docc/Extensions/BlockKeyboardTool.md: -------------------------------------------------------------------------------- 1 | # ``KeyboardToolbar/BlockKeyboardTool`` 2 | 3 | ## Topics 4 | 5 | ### Text 6 | 7 | - ``init(text:offset:action:)`` 8 | 9 | ### Image 10 | 11 | - ``init(image:action:)`` 12 | 13 | ### Symbol 14 | 15 | - ``init(symbolName:pointSize:action:)`` 16 | - ``init(symbolName:withConfiguration:action:)`` 17 | 18 | ### Displaying the Tool 19 | 20 | - ``displayRepresentation`` 21 | 22 | ### Performing the Action 23 | 24 | - ``actionHandler`` 25 | - ``performAction()`` 26 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Documentation.docc/Extensions/KeyboardTool.md: -------------------------------------------------------------------------------- 1 | # ``KeyboardToolbar/KeyboardTool`` 2 | 3 | ## Topics 4 | 5 | ### Displaying the Tool 6 | 7 | - ``displayRepresentation`` 8 | 9 | ### Performing an Action 10 | 11 | - ``performAction()`` 12 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Documentation.docc/Extensions/KeyboardToolDisplayRepresentation.md: -------------------------------------------------------------------------------- 1 | # ``KeyboardToolbar/KeyboardToolDisplayRepresentation`` 2 | 3 | ## Topics 4 | 5 | ### Text 6 | 7 | Display the tool using a string. 8 | 9 | - ``text(_:)`` 10 | - ``text(_:offset:)`` 11 | - ``TextConfiguration`` 12 | 13 | ### Image 14 | 15 | Display the tool using an imge. 16 | 17 | - ``image(_:)-swift.enum.case`` 18 | - ``image(_:)-swift.type.method`` 19 | - ``ImageConfiguration`` 20 | 21 | ### Symbol 22 | 23 | Display the tool using a SF Symbol. 24 | 25 | - ``symbol(named:pointSize:)`` 26 | - ``symbol(named:withConfiguration:)`` 27 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Documentation.docc/Extensions/KeyboardToolbarView.md: -------------------------------------------------------------------------------- 1 | # ``KeyboardToolbar/KeyboardToolbarView`` 2 | 3 | ## Topics 4 | 5 | ### Initialing the Toolbar 6 | 7 | - ``init()`` 8 | 9 | ### Customizing Tools 10 | 11 | - ``groups`` 12 | - ``showToolPickerDelay`` 13 | 14 | ### UIInputViewAudioFeedback Conformance 15 | 16 | - ``enableInputClicksWhenVisible`` 17 | 18 | ### Inherited 19 | 20 | - ``traitCollectionDidChange(_:)`` 21 | - ``updateConstraints()`` 22 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Documentation.docc/Resources/keyboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonbs/KeyboardToolbar/1bfef105bd74731c1fa97b2becf3be2ba20c7030/Sources/KeyboardToolbar/Documentation.docc/Resources/keyboard.png -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Documentation.docc/Resources/keyboard~dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonbs/KeyboardToolbar/1bfef105bd74731c1fa97b2becf3be2ba20c7030/Sources/KeyboardToolbar/Documentation.docc/Resources/keyboard~dark.png -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Helpers/Device.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum Device { 4 | case iPhone8 5 | case iPhone8Plus 6 | case iPhone11 7 | case iPhone11ProMax 8 | case iPhone12mini 9 | case iPhone12 10 | case iPhone12Pro 11 | case iPhone12ProMax 12 | case unknown 13 | 14 | static var current: Device { 15 | #if !os(xrOS) 16 | let bounds = UIScreen.main.nativeBounds 17 | if bounds.size.width == 750 && bounds.size.height == 1_334 { 18 | return .iPhone8 19 | } else if bounds.size.width == 1_242 && bounds.size.height == 2_208 { 20 | return .iPhone8Plus 21 | } else if bounds.size.width == 828 && bounds.size.height == 1_792 { 22 | return .iPhone11 23 | } else if bounds.size.width == 1_242 && bounds.size.height == 2_688 { 24 | return .iPhone11ProMax 25 | } else if bounds.size.width == 1_080 && bounds.size.height == 2_340 { 26 | return .iPhone12mini 27 | } else if bounds.size.width == 1_170 && bounds.size.height == 2_532 { 28 | return .iPhone12 29 | } else if bounds.size.width == 1_170 && bounds.size.height == 2_532 { 30 | return .iPhone12Pro 31 | } else if bounds.size.width == 1_284 && bounds.size.height == 2_778 { 32 | return .iPhone12ProMax 33 | } else { 34 | #if DEBUG 35 | print(bounds) 36 | #endif 37 | return .unknown 38 | } 39 | #else 40 | return .unknown 41 | #endif 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Helpers/InputToolMargin.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum InputToolMargin { 4 | static var rawValue: CGFloat { 5 | #if !os(xrOS) 6 | if UIDevice.current.userInterfaceIdiom == .pad { 7 | return 5 8 | } else if UIScreen.main.bounds.height > UIScreen.main.bounds.width { 9 | return 3 10 | } else { 11 | switch Device.current { 12 | case .iPhone8: 13 | return 72 14 | case .iPhone8Plus: 15 | return 76 16 | case .iPhone11, .iPhone11ProMax, .iPhone12ProMax: 17 | return 120 18 | case .iPhone12mini, .iPhone12, .iPhone12Pro, .unknown: 19 | return 78 20 | } 21 | } 22 | #else 23 | return 5 24 | #endif 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Helpers/UIColor+Helpers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIColor { 4 | static var keyboardToolButtonPrimary: UIColor { 5 | return inModule(colorName: "keyboard_tool_button_primary") 6 | } 7 | static var keyboardToolButtonSecondary: UIColor { 8 | return inModule(colorName: "keyboard_tool_button_secondary") 9 | } 10 | static var keyboardToolForegroundHighlighted: UIColor { 11 | return inModule(colorName: "keyboard_tool_foreground_highlighted") 12 | } 13 | static var keyboardToolForeground: UIColor { 14 | return inModule(colorName: "keyboard_tool_foreground") 15 | } 16 | } 17 | 18 | private extension UIColor { 19 | private static func inModule(colorName: String) -> UIColor { 20 | return UIColor(named: colorName, in: .module, compatibleWith: nil)! 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Helpers/UIImage+Helpers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage { 4 | var firstPixelColor: UIColor? { 5 | guard let inputImage = CIImage(image: self) else { 6 | return nil 7 | } 8 | var bitmap = [UInt8](repeating: 0, count: 4) 9 | let context = CIContext(options: [.workingColorSpace: kCFNull as Any]) 10 | let bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height) 11 | context.render(inputImage, toBitmap: &bitmap, rowBytes: 4, bounds: bounds, format: .RGBA8, colorSpace: nil) 12 | let red = CGFloat(bitmap[0]) / 255 13 | let green = CGFloat(bitmap[1]) / 255 14 | let blue = CGFloat(bitmap[2]) / 255 15 | let alpha = CGFloat(bitmap[3]) / 255 16 | return UIColor(red: red, green: green, blue: blue, alpha: alpha) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Helpers/UIView+Helpers.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | func color(at point: CGPoint) -> UIColor? { 5 | guard let window = window else { 6 | return nil 7 | } 8 | let buttonFrame = window.convert(frame, from: superview) 9 | let bounds = CGRect(x: buttonFrame.minX + point.x, y: buttonFrame.minY, width: 1, height: 1) 10 | let imageRenderer = UIGraphicsImageRenderer(bounds: bounds) 11 | let format = UIGraphicsImageRendererFormat() 12 | format.scale = 1 13 | format.opaque = true 14 | let image = imageRenderer.image { _ in 15 | window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) 16 | } 17 | return image.firstPixelColor 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/KeyboardTool.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A tool to be dispayed in an instance of ``KeyboardToolbarView``. 4 | /// 5 | /// Tools belong to an instance of ``KeyboardToolGroupItem``. The ``KeyboardTool/performAction()`` function is called when the toolbar is selected. 6 | /// 7 | /// The instance of ``KeyboardToolDisplayRepresentation`` specifies the appearance of the tool in the toolbar. 8 | /// 9 | /// The KeyboardTool protocol can be used to encapsulate advanced actions in your codebase. Consider using ``BlockKeyboardTool`` for simple actions. 10 | public protocol KeyboardTool { 11 | /// Specifies how the tool should be displayed in the toolbar. 12 | var displayRepresentation: KeyboardToolDisplayRepresentation { get } 13 | /// The function to be called when the tool is selected. 14 | func performAction() 15 | } 16 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/KeyboardToolDisplayRepresentation.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// The display of a keyboard tool. 4 | /// 5 | /// This specifies how an instance of ``KeyboardTool`` is displayed when shown in a ``KeyboardToolbar``. 6 | public enum KeyboardToolDisplayRepresentation { 7 | /// Configuration of a tool displayed using a string. 8 | public struct TextConfiguration { 9 | /// The string to use when displaying the tool. 10 | /// 11 | /// It is recommended to limit this to a single character. 12 | public let text: String 13 | /// Offset to be applied to the text when shown in a button. 14 | /// 15 | /// This can be used to adjust the alignment of the text. 16 | /// 17 | /// Defaults to (0, 0). 18 | public let offset: CGPoint 19 | 20 | /// Initializes a configuration. 21 | /// - Parameters: 22 | /// - text: The string to use when displaying the tool. 23 | /// - offset: Offset to be applied to the text when shown in a button. Defaults to (0, 0). 24 | public init(text: String, offset: CGPoint = .zero) { 25 | self.text = text 26 | self.offset = offset 27 | } 28 | } 29 | 30 | /// Configuration of a tool displayed using an image. 31 | public struct ImageConfiguration { 32 | /// The image to use when displaying the tool. 33 | public let smallImage: UIImage 34 | /// The image to use when momentarily displaying the tool picker. 35 | public let largeImage: UIImage 36 | 37 | /// Initializes a configuration. 38 | /// - Parameter smallImage: The image to use when displaying the tool. 39 | /// - Parameter largeImage: The image to use when momentarily displaying the tool picker. 40 | public init(smallImage: UIImage, largeImage: UIImage) { 41 | self.smallImage = smallImage 42 | self.largeImage = largeImage 43 | } 44 | } 45 | 46 | /// Configuration of a tool displayed using an SF symbol. 47 | public struct SymbolConfiguration { 48 | /// The name of the symbol to use when displaying the tool. 49 | public let symbolName: String 50 | /// The point size of the symbol. 51 | public let pointSize: CGFloat 52 | 53 | /// Initializes a configuration. 54 | /// - Parameters: 55 | /// - symbolName: The name of the symbol to use when displaying the tool. 56 | /// - pointSize: The point size of the symbol. Defaults to 14. 57 | public init(symbolName: String, pointSize: CGFloat = 14) { 58 | self.symbolName = symbolName 59 | self.pointSize = pointSize 60 | } 61 | } 62 | 63 | /// Display the tool using a string. 64 | case text(TextConfiguration) 65 | /// Display the tool using an image. 66 | case image(ImageConfiguration) 67 | /// Display the tool using an SF symbol. 68 | case symbol(SymbolConfiguration) 69 | 70 | /// Creates a display representation that displays a tool using a string. 71 | /// - Parameters: 72 | /// - text: The string to use when displaying the tool. 73 | /// - offset: Offset to be applied to the text when shown in a button. Defaults to (0, 0). 74 | /// - Returns: A display representation. 75 | public static func text(_ text: String, offset: CGPoint = .zero) -> Self { 76 | return .text(TextConfiguration(text: text, offset: offset)) 77 | } 78 | 79 | /// Creates a display representation that displays a tool using an image. 80 | /// - Parameter smallImage: The image to use when displaying the tool. 81 | /// - Parameter largeImage: The image to use when momentarily displaying the tool picker. 82 | /// - Returns: A display representation. 83 | public static func image(small smallImage: UIImage, large largeImage: UIImage) -> Self { 84 | return .image(ImageConfiguration(smallImage: smallImage, largeImage: largeImage)) 85 | } 86 | 87 | /// Creates a display representation that displays a tool using an SF Symbol. 88 | /// - Parameters: 89 | /// - symbolName: The name of the symbol to use when displaying the tool. 90 | /// - pointSize: The point size of the symbol. Defaults to 14. 91 | /// - Returns: A display representation. 92 | public static func symbol(named symbolName: String, pointSize: CGFloat = 14) -> Self { 93 | return .symbol(SymbolConfiguration(symbolName: symbolName, pointSize: pointSize)) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/KeyboardToolGroup.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | import Foundation 3 | 4 | /// A group of items to be displayed in a toolbar. 5 | /// 6 | /// Tool groups are evenly distributed in the toolbar. Use ``KeyboardToolGroup/spacing`` to specify the spacing within the group. 7 | public struct KeyboardToolGroup { 8 | /// The spacing between items in the group. 9 | /// 10 | /// The default value is 6. 11 | public let spacing: CGFloat 12 | /// The items to be displayed in the group. 13 | public let items: [KeyboardToolGroupItem] 14 | 15 | /// Initializes a tool group. 16 | /// - Parameters: 17 | /// - spacing: The spacing between items in the group. Defaults to 6. 18 | /// - items: The items to be displayed in the group. 19 | public init(spacing: CGFloat = 6, items: [KeyboardToolGroupItem]) { 20 | self.spacing = spacing 21 | self.items = items 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/KeyboardToolGroupItem.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// An item displayed in a tool group. 4 | public struct KeyboardToolGroupItem { 5 | /// The style of an item. 6 | public enum Style { 7 | /// A prominent style. 8 | /// 9 | /// Typically used for items that presents the tool picker when long pressed. 10 | case primary 11 | /// A faded style. 12 | /// 13 | /// Typically used for items that does not present the tool picker when long pressed. 14 | case secondary 15 | } 16 | 17 | /// The style of the item. 18 | public let style: Style 19 | /// The tool representing the item. 20 | /// 21 | /// This tool is the default tool. Selecting the item will perform the action on this tool. 22 | public let representativeTool: KeyboardTool 23 | /// Insets applied to a button showing this item. 24 | public let contentEdgeInsets: NSDirectionalEdgeInsets 25 | /// Whether this item is enabled or not. 26 | public let isEnabled: Bool 27 | /// Whether to include the representative tool in the tool picker that is presented when long pressing the item. 28 | public let includeRepresentativeToolInPicker: Bool 29 | /// The items to be displayed in the tool picker that is presented when long pressing the item. 30 | public let tools: [KeyboardTool] 31 | 32 | var allTools: [KeyboardTool] { 33 | if includeRepresentativeToolInPicker { 34 | return [representativeTool] + tools 35 | } else { 36 | return tools 37 | } 38 | } 39 | 40 | /// Initializes an item. 41 | /// - Parameters: 42 | /// - style: The style of the item. 43 | /// - representativeTool: The tool representing the item. 44 | /// - contentEdgeInsets: Insets applied to a button showing this item. 45 | /// - isEnabled: Whether this item is enabled or not. 46 | /// - includeRepresentativeToolInPicker: Whether to include the representative tool in the tool picker that is presented when long pressing the item. 47 | /// - tools: The items to be displayed in the tool picker that is presented when long pressing the item. 48 | public init(style: Style = .primary, 49 | representativeTool: KeyboardTool, 50 | contentEdgeInsets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 1, trailing: 0), 51 | isEnabled: Bool = true, 52 | includeRepresentativeToolInPicker: Bool = false, 53 | tools: [KeyboardTool] = []) { 54 | self.style = style 55 | self.representativeTool = representativeTool 56 | self.contentEdgeInsets = contentEdgeInsets 57 | self.isEnabled = isEnabled 58 | self.includeRepresentativeToolInPicker = includeRepresentativeToolInPicker 59 | self.tools = tools 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/KeyboardToolbarView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Toolbar to be displayed above the keyboard. 4 | /// 5 | /// Set an instance of this view as the `inputAccessoryView` on a text view or text field to display tools above the keyboard. 6 | public final class KeyboardToolbarView: UIInputView, UIInputViewAudioFeedback { 7 | /// Tool groups to be displayed in the toolbar. 8 | public var groups: [KeyboardToolGroup] = [] { 9 | didSet { 10 | reloadBarButtonItems() 11 | } 12 | } 13 | /// Duration a user should long press an item to present the tool picker. 14 | public var showToolPickerDelay: TimeInterval = 0.5 { 15 | didSet { 16 | if showToolPickerDelay != oldValue { 17 | for button in toolButtons { 18 | button.showToolPickerDelay = showToolPickerDelay 19 | } 20 | } 21 | } 22 | } 23 | #if !os(xrOS) 24 | /// Enables clicks when selecting a tool. 25 | public var enableInputClicksWhenVisible: Bool { 26 | return true 27 | } 28 | #endif 29 | 30 | private let keyboardContentLayoutGuide = UILayoutGuide() 31 | private var keyboardContentLayoutGuideLeadingConstraint: NSLayoutConstraint? 32 | private var keyboardContentLayoutGuideTrailingConstraint: NSLayoutConstraint? 33 | private let toolbar: UIToolbar = { 34 | let this = UIToolbar() 35 | this.translatesAutoresizingMaskIntoConstraints = false 36 | this.setBackgroundImage(UIImage(), forToolbarPosition: .any, barMetrics: .default) 37 | this.setShadowImage(UIImage(), forToolbarPosition: .any) 38 | return this 39 | }() 40 | private var toolButtons: [KeyboardToolButton] { 41 | let items = toolbar.items ?? [] 42 | return items.compactMap { barButtonItem in 43 | return barButtonItem.customView as? KeyboardToolButton 44 | } 45 | } 46 | 47 | /// Initializes a new toolbar to be shown above a keyboard. 48 | public init() { 49 | let frame = CGRect(x: 0, y: 0, width: 0, height: 46) 50 | super.init(frame: frame, inputViewStyle: .keyboard) 51 | setupView() 52 | setupLayout() 53 | } 54 | 55 | required init?(coder aDecoder: NSCoder) { 56 | fatalError("init(coder:) has not been implemented") 57 | } 58 | 59 | private func setupView() { 60 | backgroundColor = .clear 61 | addLayoutGuide(keyboardContentLayoutGuide) 62 | addSubview(toolbar) 63 | } 64 | 65 | private func setupLayout() { 66 | keyboardContentLayoutGuideLeadingConstraint = keyboardContentLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor) 67 | keyboardContentLayoutGuideTrailingConstraint = keyboardContentLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor) 68 | NSLayoutConstraint.activate([ 69 | keyboardContentLayoutGuideLeadingConstraint!, 70 | keyboardContentLayoutGuideTrailingConstraint!, 71 | keyboardContentLayoutGuide.topAnchor.constraint(equalTo: topAnchor), 72 | keyboardContentLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), 73 | 74 | toolbar.leadingAnchor.constraint(equalTo: keyboardContentLayoutGuide.leadingAnchor), 75 | toolbar.trailingAnchor.constraint(equalTo: keyboardContentLayoutGuide.trailingAnchor), 76 | toolbar.topAnchor.constraint(equalTo: keyboardContentLayoutGuide.topAnchor), 77 | toolbar.bottomAnchor.constraint(equalTo: keyboardContentLayoutGuide.bottomAnchor) 78 | ]) 79 | } 80 | 81 | public override func updateConstraints() { 82 | super.updateConstraints() 83 | keyboardContentLayoutGuideLeadingConstraint?.constant = InputToolMargin.rawValue 84 | keyboardContentLayoutGuideTrailingConstraint?.constant = InputToolMargin.rawValue * -1 85 | } 86 | 87 | public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 88 | super.traitCollectionDidChange(previousTraitCollection) 89 | setNeedsUpdateConstraints() 90 | } 91 | } 92 | 93 | private extension KeyboardToolbarView { 94 | private func reloadBarButtonItems() { 95 | let toolbarEdgePadding: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 20 : 16 96 | var barButtonItems: [UIBarButtonItem] = [.fixedSpace(-toolbarEdgePadding)] 97 | for (idx, group) in groups.enumerated() { 98 | for (idx, item) in group.items.enumerated() { 99 | let button = KeyboardToolButton(item: item) 100 | button.translatesAutoresizingMaskIntoConstraints = false 101 | button.showToolPickerDelay = showToolPickerDelay 102 | barButtonItems += [UIBarButtonItem(customView: button)] 103 | if group.spacing != 0 && idx < group.items.count - 1 { 104 | barButtonItems += [.fixedSpace(group.spacing)] 105 | } 106 | } 107 | if idx < groups.count - 1 { 108 | barButtonItems += [.flexibleSpace()] 109 | } 110 | } 111 | barButtonItems += [.fixedSpace(-toolbarEdgePadding)] 112 | toolbar.items = barButtonItems 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Views/KeyboardToolButton.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class KeyboardToolButton: UIButton { 4 | private enum ToolPickerDirection { 5 | case left 6 | case right 7 | } 8 | 9 | var showToolPickerDelay: TimeInterval = 0.5 10 | 11 | override var intrinsicContentSize: CGSize { 12 | let imageWidth = imageView?.image?.size.width ?? 0 13 | let paddedImageWidth = imageWidth + imagePadding * 2 14 | let width = max(paddedImageWidth, 32) 15 | return CGSize(width: width, height: 42) 16 | } 17 | override var isHighlighted: Bool { 18 | didSet { 19 | updateBackgroundColor() 20 | } 21 | } 22 | 23 | private let backgroundView: KeyboardToolButtonBackgroundView = { 24 | let view = KeyboardToolButtonBackgroundView() 25 | view.isUserInteractionEnabled = false 26 | return view 27 | }() 28 | private let toolPickerView = KeyboardToolPickerView() 29 | private let toolPickerBackgroundView = KeyboardToolPickerBackgroundView() 30 | private var toolPickerTimer: Timer? 31 | #if !os(xrOS) 32 | private let feedbackGenerator = UISelectionFeedbackGenerator() 33 | #endif 34 | private let item: KeyboardToolGroupItem 35 | private var imagePadding: CGFloat = 5 36 | private var backgroundInsets: UIEdgeInsets = .zero { 37 | didSet { 38 | if backgroundInsets != oldValue { 39 | setNeedsLayout() 40 | } 41 | } 42 | } 43 | private var toolPickerBackgroundColor: UIColor { 44 | // Get the background color from a screenshot to ensure we get the right color for all keyboards. 45 | // This is in particular relevant when using dark keyboards where both the keyboard and the buttons are transulcent. 46 | let colorSamplePoint = CGPoint(x: frame.size.width / 2, y: 0) 47 | return color(at: colorSamplePoint) ?? .black 48 | } 49 | 50 | init(item: KeyboardToolGroupItem) { 51 | self.item = item 52 | super.init(frame: .zero) 53 | if #available(iOS 15, *) { 54 | configuration = .plain() 55 | configuration?.contentInsets = item.contentEdgeInsets 56 | } else { 57 | contentEdgeInsets = UIEdgeInsets(item.contentEdgeInsets) 58 | } 59 | tintColor = .label 60 | setTitleColor(.label, for: .normal) 61 | adjustsImageSizeForAccessibilityContentSizeCategory = true 62 | if #available(iOS 15, *) { 63 | maximumContentSizeCategory = .extraExtraLarge 64 | } 65 | addSubview(backgroundView) 66 | addTarget(self, action: #selector(touchDown(_:event:)), for: .touchDown) 67 | addTarget(self, action: #selector(touchUp), for: .touchUpInside) 68 | addTarget(self, action: #selector(touchUp), for: .touchUpOutside) 69 | addTarget(self, action: #selector(touchUp), for: .touchCancel) 70 | addTarget(self, action: #selector(touchDragged(_:event:)), for: .touchDragInside) 71 | addTarget(self, action: #selector(touchDragged(_:event:)), for: .touchDragOutside) 72 | setupRepresentativeTool() 73 | updateBackgroundColor() 74 | } 75 | 76 | required init?(coder: NSCoder) { 77 | fatalError("init(coder:) has not been implemented") 78 | } 79 | 80 | override func didMoveToSuperview() { 81 | super.didMoveToSuperview() 82 | isEnabled = item.isEnabled 83 | } 84 | 85 | override func layoutSubviews() { 86 | super.layoutSubviews() 87 | sendSubviewToBack(backgroundView) 88 | let backgroundWidth = bounds.width - backgroundInsets.left - backgroundInsets.right 89 | let backgroundHeight = bounds.height - backgroundInsets.top - backgroundInsets.bottom 90 | backgroundView.frame = CGRect(x: backgroundInsets.left, y: backgroundInsets.top, width: backgroundWidth, height: backgroundHeight) 91 | } 92 | 93 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 94 | super.traitCollectionDidChange(previousTraitCollection) 95 | if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { 96 | updateBackgroundColor() 97 | } 98 | } 99 | } 100 | 101 | private extension KeyboardToolButton { 102 | private func setupRepresentativeTool() { 103 | switch item.representativeTool.displayRepresentation { 104 | case .text(let configuration): 105 | titleLabel?.font = configuration.font(ofSize: .small) 106 | setTitle(configuration.text, for: .normal) 107 | case .image(let configuration): 108 | setImage(configuration.image(ofSize: .small), for: .normal) 109 | case .symbol(let configuration): 110 | setImage(configuration.image(ofSize: .small), for: .normal) 111 | } 112 | } 113 | 114 | private func setContentHidden(_ isHidden: Bool) { 115 | imageView?.isHidden = isHidden 116 | titleLabel?.isHidden = isHidden 117 | } 118 | 119 | private func updateBackgroundColor() { 120 | switch item.style { 121 | case .primary: 122 | backgroundView.fillColor = .keyboardToolButtonPrimary 123 | case .secondary: 124 | backgroundView.fillColor = isHighlighted ? .keyboardToolButtonPrimary : .keyboardToolButtonSecondary 125 | } 126 | } 127 | 128 | @objc private func touchDown(_ sender: UIButton, event: UIEvent) { 129 | toolPickerBackgroundView.fillColor = toolPickerBackgroundColor 130 | #if !os(xrOS) 131 | UIDevice.current.playInputClick() 132 | #endif 133 | cancelToolPickerTimer() 134 | let shouldPresentPicker = item.style == .primary || !item.allTools.isEmpty 135 | guard shouldPresentPicker else { 136 | return 137 | } 138 | setContentHidden(true) 139 | #if !os(xrOS) 140 | feedbackGenerator.prepare() 141 | #endif 142 | if showToolPickerDelay > 0 { 143 | presentToolPicker(with: [item.representativeTool], atSize: .large) 144 | if !item.allTools.isEmpty { 145 | schedulePresentingAllTools() 146 | } 147 | } else { 148 | presentToolPicker(with: item.allTools, atSize: .large) 149 | } 150 | } 151 | 152 | @objc private func touchUp() { 153 | setContentHidden(false) 154 | cancelToolPickerTimer() 155 | backgroundView.isHidden = false 156 | toolPickerView.removeFromSuperview() 157 | toolPickerBackgroundView.removeFromSuperview() 158 | if let highlightedIndex = toolPickerView.highlightedIndex { 159 | let tool = item.allTools[highlightedIndex] 160 | tool.performAction() 161 | } else { 162 | item.representativeTool.performAction() 163 | } 164 | } 165 | 166 | @objc private func touchDragged(_ sender: UIButton, event: UIEvent) { 167 | updateHiglightedTool(for: event) 168 | } 169 | 170 | private func schedulePresentingAllTools() { 171 | let delay = showToolPickerDelay 172 | let selector = #selector(toolPickerTimerTriggered) 173 | let timer = Timer(timeInterval: delay, target: self, selector: selector, userInfo: nil, repeats: false) 174 | toolPickerTimer = timer 175 | RunLoop.main.add(timer, forMode: .common) 176 | } 177 | 178 | private func cancelToolPickerTimer() { 179 | toolPickerTimer?.invalidate() 180 | toolPickerTimer = nil 181 | } 182 | 183 | @objc private func toolPickerTimerTriggered() { 184 | #if !os(xrOS) 185 | feedbackGenerator.selectionChanged() 186 | #endif 187 | cancelToolPickerTimer() 188 | presentToolPicker(with: item.allTools, atSize: .small) 189 | } 190 | 191 | private func presentToolPicker(with tools: [KeyboardTool], atSize contentSize: KeyboardToolContentSize) { 192 | if toolPickerBackgroundView.superview == nil { 193 | addSubview(toolPickerBackgroundView) 194 | } 195 | if toolPickerView.superview == nil { 196 | addSubview(toolPickerView) 197 | } 198 | let displayRepresentations = tools.map(\.displayRepresentation) 199 | toolPickerView.show(displayRepresentations, atSize: contentSize) 200 | toolPickerView.leadingSpacing = 10 201 | toolPickerView.trailingSpacing = 10 202 | toolPickerView.toolSize = frame.size 203 | toolPickerBackgroundView.handleSize = KeyboardToolPickerFrameCalculator.handleSize(from: self) 204 | let toolPickerLayout = toolPickerLayout(forShowingNumberOfTools: tools.count) 205 | toolPickerView.showReversed = toolPickerLayout.isReverse 206 | toolPickerView.frame = toolPickerLayout.pickerFrame 207 | toolPickerBackgroundView.preferredHandleXPosition = toolPickerLayout.handleXPosition 208 | toolPickerBackgroundView.frame = toolPickerLayout.backgroundFrame 209 | backgroundView.isHidden = true 210 | if tools.count > 1 { 211 | toolPickerView.highlightTool(at: 0) 212 | } else { 213 | toolPickerView.clearHighlightedTool() 214 | } 215 | } 216 | 217 | private func updateHiglightedTool(for event: UIEvent) { 218 | if toolPickerView.toolCount > 1, let touch = event.allTouches?.first { 219 | let location = touch.location(in: toolPickerView) 220 | let oldHighlightedIndex = toolPickerView.highlightedIndex 221 | toolPickerView.highlightTool(closestTo: location) 222 | if toolPickerView.highlightedIndex != oldHighlightedIndex { 223 | #if !os(xrOS) 224 | feedbackGenerator.selectionChanged() 225 | #endif 226 | } 227 | } else { 228 | toolPickerView.clearHighlightedTool() 229 | } 230 | } 231 | 232 | private func toolPickerLayout(forShowingNumberOfTools toolCount: Int) -> KeyboardToolPickerLayout { 233 | if toolCount == 1 { 234 | return KeyboardToolPickerFrameCalculator.singleToolLayout(forPresenting: toolPickerView, and: toolPickerBackgroundView, from: self) 235 | } else { 236 | return KeyboardToolPickerFrameCalculator.multipleToolsLayout(forPresenting: toolPickerView, and: toolPickerBackgroundView, from: self) 237 | } 238 | } 239 | } 240 | 241 | private extension UIEdgeInsets { 242 | init(_ edgeInsets: NSDirectionalEdgeInsets) { 243 | self.init(top: edgeInsets.top, left: edgeInsets.leading, bottom: edgeInsets.bottom, right: edgeInsets.trailing) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Views/KeyboardToolButtonBackgroundView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class KeyboardToolButtonBackgroundView: UIView { 4 | var cornerRadius: CGFloat = 5 { 5 | didSet { 6 | if cornerRadius != oldValue { 7 | setNeedsDisplay() 8 | } 9 | } 10 | } 11 | var fillColor: UIColor? { 12 | didSet { 13 | if fillColor != oldValue { 14 | setNeedsDisplay() 15 | } 16 | } 17 | } 18 | var shadowColor: UIColor? = .black.withAlphaComponent(0.2) { 19 | didSet { 20 | if shadowColor != oldValue { 21 | setNeedsDisplay() 22 | } 23 | } 24 | } 25 | var shadowLength: CGFloat = 1 { 26 | didSet { 27 | if shadowLength != oldValue { 28 | setNeedsDisplay() 29 | } 30 | } 31 | } 32 | 33 | override init(frame: CGRect) { 34 | super.init(frame: frame) 35 | backgroundColor = .clear 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | override func draw(_ rect: CGRect) { 43 | super.draw(rect) 44 | let context = UIGraphicsGetCurrentContext() 45 | context?.clear(rect) 46 | if let fillColor = fillColor { 47 | let fillRect = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height - shadowLength) 48 | let fillPath = CGPath(roundedRect: fillRect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) 49 | if let shadowColor = shadowColor, shadowLength != 0 { 50 | var transform = CGAffineTransform(translationX: 0, y: shadowLength) 51 | if let shadowPath = fillPath.copy(using: &transform) { 52 | context?.saveGState() 53 | // Draw the shadow 54 | context?.addPath(shadowPath) 55 | context?.setFillColor(shadowColor.cgColor) 56 | context?.fillPath() 57 | // Cut the filled rectangle out of the shadow so the shadow underneath isn't visible when the fill color isn't fully opaque. 58 | context?.addPath(fillPath) 59 | context?.setBlendMode(.clear) 60 | context?.setFillColor(UIColor.black.cgColor) 61 | context?.fillPath() 62 | context?.restoreGState() 63 | } 64 | } 65 | // Draw the rectangle that will be visible. 66 | context?.addPath(fillPath) 67 | context?.setFillColor(fillColor.cgColor) 68 | context?.fillPath() 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Views/KeyboardToolContentSize.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum KeyboardToolContentSize { 4 | case small 5 | case large 6 | } 7 | 8 | extension KeyboardToolDisplayRepresentation.TextConfiguration { 9 | func font(ofSize size: KeyboardToolContentSize) -> UIFont { 10 | switch size { 11 | case .small: 12 | return .systemFont(ofSize: 20) 13 | case .large: 14 | return .systemFont(ofSize: 30) 15 | } 16 | } 17 | } 18 | 19 | extension KeyboardToolDisplayRepresentation.ImageConfiguration { 20 | func image(ofSize size: KeyboardToolContentSize) -> UIImage { 21 | switch size { 22 | case .small: 23 | return smallImage 24 | case .large: 25 | return largeImage 26 | } 27 | } 28 | } 29 | 30 | extension KeyboardToolDisplayRepresentation.SymbolConfiguration { 31 | func image(ofSize size: KeyboardToolContentSize) -> UIImage? { 32 | switch size { 33 | case .small: 34 | let configuration = UIImage.SymbolConfiguration(pointSize: 14) 35 | return UIImage(systemName: symbolName, withConfiguration: configuration) 36 | case .large: 37 | let configuration = UIImage.SymbolConfiguration(pointSize: 21) 38 | return UIImage(systemName: symbolName, withConfiguration: configuration) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Views/KeyboardToolPickerBackgroundView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class KeyboardToolPickerBackgroundView: UIView { 4 | var preferredHandleXPosition: CGFloat = 0 { 5 | didSet { 6 | if preferredHandleXPosition != oldValue { 7 | setNeedsDisplay() 8 | } 9 | } 10 | } 11 | var plateHeight: CGFloat = 54 { 12 | didSet { 13 | if plateHeight != oldValue { 14 | invalidateIntrinsicContentSize() 15 | setNeedsDisplay() 16 | } 17 | } 18 | } 19 | var handleSize = CGSize(width: 30, height: 30) { 20 | didSet { 21 | if handleSize != oldValue { 22 | invalidateIntrinsicContentSize() 23 | setNeedsDisplay() 24 | } 25 | } 26 | } 27 | var plateRadius: CGFloat = 10 { 28 | didSet { 29 | if plateRadius != oldValue { 30 | setNeedsDisplay() 31 | } 32 | } 33 | } 34 | var handleRadius: CGFloat = 5 { 35 | didSet { 36 | if handleRadius != oldValue { 37 | setNeedsDisplay() 38 | } 39 | } 40 | } 41 | var fillColor: UIColor = .white { 42 | didSet { 43 | if fillColor != oldValue { 44 | setNeedsDisplay() 45 | } 46 | } 47 | } 48 | var strokeColor: UIColor = .separator { 49 | didSet { 50 | if strokeColor != oldValue { 51 | setNeedsDisplay() 52 | } 53 | } 54 | } 55 | var shadowColor: UIColor? = .black.withAlphaComponent(0.2) { 56 | didSet { 57 | if shadowColor != oldValue { 58 | setNeedsDisplay() 59 | } 60 | } 61 | } 62 | var shadowBlur: CGFloat = 5 { 63 | didSet { 64 | if shadowBlur != oldValue { 65 | setNeedsDisplay() 66 | } 67 | } 68 | } 69 | var handleShadowColor: UIColor? = .black.withAlphaComponent(0.2) { 70 | didSet { 71 | if shadowColor != oldValue { 72 | setNeedsDisplay() 73 | } 74 | } 75 | } 76 | var handleShadowLength: CGFloat = 1 { 77 | didSet { 78 | if handleShadowLength != oldValue { 79 | setNeedsDisplay() 80 | } 81 | } 82 | } 83 | override var frame: CGRect { 84 | didSet { 85 | if frame.size != oldValue.size { 86 | setNeedsDisplay() 87 | } 88 | } 89 | } 90 | 91 | private var insetBounds: CGRect { 92 | return bounds.inset(by: UIEdgeInsets(top: shadowBlur, left: shadowBlur, bottom: 0, right: shadowBlur)) 93 | } 94 | private var handleRect: CGRect { 95 | let handleXPosition = preferredHandleXPosition 96 | let handleOrigin = CGPoint(x: handleXPosition, y: insetBounds.maxY - handleSize.height - shadowBlur - handleShadowLength) 97 | return CGRect(origin: handleOrigin, size: handleSize) 98 | } 99 | 100 | override init(frame: CGRect) { 101 | super.init(frame: frame) 102 | backgroundColor = .clear 103 | } 104 | 105 | required init?(coder: NSCoder) { 106 | fatalError("init(coder:) has not been implemented") 107 | } 108 | 109 | override func draw(_ rect: CGRect) { 110 | super.draw(rect) 111 | let context = UIGraphicsGetCurrentContext() 112 | context?.clear(rect) 113 | if let context = context { 114 | let path = makeBackgroundPath() 115 | drawShadow(of: path, to: context) 116 | drawHandleShadow(to: context) 117 | drawFill(of: path, to: context) 118 | drawStroke(of: path, to: context) 119 | } 120 | } 121 | 122 | func preferredSize(containingContentWidth contentWidth: CGFloat) -> CGSize { 123 | let width = contentWidth + shadowBlur * 2 124 | let height = plateHeight + handleSize.height + shadowBlur * 2 125 | return CGSize(width: width, height: height) 126 | } 127 | } 128 | 129 | private extension KeyboardToolPickerBackgroundView { 130 | private func makeGradientMaskImage() -> UIImage { 131 | let renderer = UIGraphicsImageRenderer(size: bounds.size) 132 | return renderer.image { rendererContext in 133 | let context = rendererContext.cgContext 134 | let colorSpace = CGColorSpaceCreateDeviceRGB() 135 | let colors = [ 136 | UIColor.black.withAlphaComponent(1).cgColor, 137 | UIColor.black.withAlphaComponent(0).cgColor 138 | ] 139 | let startLocation = (bounds.height - shadowBlur - handleRect.height / 2) / bounds.height 140 | let endLocation = (bounds.height - shadowBlur) / bounds.height 141 | let locations: [CGFloat] = [1 - startLocation, 1 - endLocation] 142 | let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations)! 143 | let startPoint: CGPoint = .zero 144 | let endPoint = CGPoint(x: 0, y: bounds.size.height) 145 | context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: []) 146 | } 147 | } 148 | 149 | private func drawShadow(of path: CGPath, to context: CGContext) { 150 | if let shadowColor = shadowColor, shadowBlur != 0, let maskImage = makeGradientMaskImage().cgImage { 151 | let shadowOffset = CGSize(width: 0, height: 2) 152 | let rect = CGRect(origin: .zero, size: bounds.size) 153 | context.saveGState() 154 | context.clip(to: rect, mask: maskImage) 155 | context.addPath(path) 156 | context.setFillColor(UIColor.white.cgColor) 157 | context.setShadow(offset: shadowOffset, blur: shadowBlur, color: shadowColor.cgColor) 158 | context.setBlendMode(.multiply) 159 | context.fillPath() 160 | context.restoreGState() 161 | } 162 | } 163 | 164 | private func drawHandleShadow(to context: CGContext) { 165 | if let handleShadowColor = handleShadowColor, handleShadowLength > 0 { 166 | let handlePath = UIBezierPath(roundedRect: handleRect, cornerRadius: handleRadius) 167 | let handleShadowRect = handleRect.offsetBy(dx: 0, dy: handleShadowLength) 168 | let handleShadowPath = UIBezierPath(roundedRect: handleShadowRect, cornerRadius: handleRadius) 169 | context.saveGState() 170 | // Draw the shadow 171 | context.addPath(handleShadowPath.cgPath) 172 | context.setFillColor(handleShadowColor.cgColor) 173 | context.fillPath() 174 | // Cut the filled rectangle out of the shadow so the shadow underneath isn't visible when the fill color isn't fully opaque. 175 | context.addPath(handlePath.cgPath) 176 | context.setBlendMode(.clear) 177 | context.setFillColor(UIColor.black.cgColor) 178 | context.fillPath() 179 | context.restoreGState() 180 | } 181 | } 182 | 183 | private func drawFill(of path: CGPath, to context: CGContext) { 184 | context.addPath(path) 185 | context.setFillColor(fillColor.cgColor) 186 | context.fillPath() 187 | } 188 | 189 | private func drawStroke(of path: CGPath, to context: CGContext) { 190 | context.saveGState() 191 | context.setStrokeColor(strokeColor.cgColor) 192 | #if !os(xrOS) 193 | context.setLineWidth(1 / UIScreen.main.scale) 194 | #else 195 | context.setLineWidth(1) 196 | #endif 197 | context.addPath(path) 198 | context.strokePath() 199 | context.restoreGState() 200 | } 201 | 202 | private func makeBackgroundPath() -> CGPath { 203 | let drawingTechniqueBufferDistance = handleSize.width * 0.8 204 | 205 | let pPlateTopLeftCorner = CGPoint(x: insetBounds.minX, y: insetBounds.minY) 206 | let pPlateTopRightCorner = CGPoint(x: insetBounds.maxX, y: insetBounds.minY) 207 | let pPlateBottomRightCorner = CGPoint(x: insetBounds.maxX, y: handleRect.minY) 208 | let pPlateBottomLeftCorner = CGPoint(x: insetBounds.minX, y: handleRect.minY) 209 | 210 | let pHandleTopRightCorner = CGPoint(x: handleRect.maxX, y: handleRect.minY) 211 | let pHandleBottomRightCorner = CGPoint(x: handleRect.maxX, y: handleRect.maxY) 212 | let pHandleBottomLeftCorner = CGPoint(x: handleRect.minX, y: handleRect.maxY) 213 | let pHandleTopLeftCorner = CGPoint(x: handleRect.minX, y: handleRect.minY) 214 | 215 | let path = CGMutablePath() 216 | path.move(to: CGPoint(x: insetBounds.minX, y: insetBounds.minY + plateRadius)) 217 | 218 | path.addArc(corner: pPlateTopLeftCorner, radius: plateRadius, circleComponent: .topLeft) 219 | path.addArc(corner: pPlateTopRightCorner, radius: plateRadius, circleComponent: .topRight) 220 | 221 | // Choose drawing technique depending on the distance between the handle's right-hand side and the plate's right-hand side. 222 | if insetBounds.width - handleRect.maxX <= drawingTechniqueBufferDistance { 223 | path.addLine(to: pPlateBottomRightCorner.offsetting(y: -plateRadius)) 224 | path.addCurve(to: pHandleTopRightCorner.offsetting(y: plateRadius), control1: pPlateBottomRightCorner, control2: pHandleTopRightCorner) 225 | } else { 226 | path.addArc(corner: pPlateBottomRightCorner, radius: plateRadius, circleComponent: .bottomRight) 227 | path.addLine(to: pHandleTopRightCorner.offsetting(x: plateRadius)) 228 | path.addQuadCurve(to: pHandleTopRightCorner.offsetting(y: plateRadius), control: pHandleTopRightCorner) 229 | } 230 | 231 | path.addArc(corner: pHandleBottomRightCorner, radius: handleRadius, circleComponent: .bottomRight) 232 | path.addArc(corner: pHandleBottomLeftCorner, radius: handleRadius, circleComponent: .bottomLeft) 233 | 234 | // Choose drawing technique depending on the distance between the handle's left-hand side and the plate's left-hand side. 235 | if handleRect.minX <= drawingTechniqueBufferDistance { 236 | path.addLine(to: pHandleTopLeftCorner.offsetting(y: plateRadius)) 237 | path.addCurve(to: pPlateBottomLeftCorner.offsetting(y: -plateRadius), control1: pHandleTopLeftCorner, control2: pPlateBottomLeftCorner) 238 | } else { 239 | path.addLine(to: pHandleTopLeftCorner.offsetting(y: plateRadius)) 240 | path.addQuadCurve(to: pHandleTopLeftCorner.offsetting(x: -plateRadius), control: pHandleTopLeftCorner) 241 | path.addArc(corner: pPlateBottomLeftCorner, radius: plateRadius, circleComponent: .bottomLeft) 242 | } 243 | 244 | path.closeSubpath() 245 | return path 246 | } 247 | } 248 | 249 | private extension CGPoint { 250 | func offsetting(x dx: CGFloat = 0, y dy: CGFloat = 0) -> CGPoint { 251 | return CGPoint(x: x + dx, y: y + dy) 252 | } 253 | } 254 | 255 | private extension CGMutablePath { 256 | enum CircleComponent { 257 | case topRight 258 | case bottomRight 259 | case bottomLeft 260 | case topLeft 261 | 262 | var startAngle: CGFloat { 263 | switch self { 264 | case .topRight: 265 | return 3 * .pi / 2 266 | case .bottomRight: 267 | return 2 * .pi 268 | case .bottomLeft: 269 | return .pi / 2 270 | case .topLeft: 271 | return .pi 272 | } 273 | } 274 | 275 | var endAngle: CGFloat { 276 | switch self { 277 | case .topRight: 278 | return 2 * .pi 279 | case .bottomRight: 280 | return .pi / 2 281 | case .bottomLeft: 282 | return .pi 283 | case .topLeft: 284 | return 3 * .pi / 2 285 | } 286 | } 287 | } 288 | 289 | func addArc(corner: CGPoint, radius: CGFloat, circleComponent: CircleComponent, clockwise: Bool = false) { 290 | let center: CGPoint 291 | switch circleComponent { 292 | case .topRight: 293 | center = CGPoint(x: corner.x - radius, y: corner.y + radius) 294 | case .bottomRight: 295 | center = CGPoint(x: corner.x - radius, y: corner.y - radius) 296 | case .bottomLeft: 297 | center = CGPoint(x: corner.x + radius, y: corner.y - radius) 298 | case .topLeft: 299 | center = CGPoint(x: corner.x + radius, y: corner.y + radius) 300 | } 301 | addArc(center: center, radius: radius, circleComponent: circleComponent, clockwise: clockwise) 302 | } 303 | 304 | func addArc(center: CGPoint, radius: CGFloat, circleComponent: CircleComponent, clockwise: Bool = false) { 305 | addArc(center: center, radius: radius, startAngle: circleComponent.startAngle, endAngle: circleComponent.endAngle, clockwise: clockwise) 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Views/KeyboardToolPickerFrameCalculator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | private enum KeyboardToolPickerDirection { 4 | case left 5 | case right 6 | } 7 | 8 | struct KeyboardToolPickerLayout { 9 | let backgroundFrame: CGRect 10 | let pickerFrame: CGRect 11 | let handleXPosition: CGFloat 12 | let isReverse: Bool 13 | } 14 | 15 | struct KeyboardToolPickerFrameCalculator { 16 | static func handleSize(from handleView: UIView) -> CGSize { 17 | return CGSize(width: handleView.frame.width, height: handleView.frame.height + 10) 18 | } 19 | 20 | static func singleToolLayout(forPresenting toolPickerView: KeyboardToolPickerView, 21 | and toolPickerBackgroundView: KeyboardToolPickerBackgroundView, 22 | from presentingView: UIView) -> KeyboardToolPickerLayout { 23 | let handleSize = handleSize(from: presentingView) 24 | let backgroundSize = toolPickerBackgroundView.preferredSize(containingContentWidth: toolPickerView.intrinsicContentSize.width) 25 | let frameInWindow = presentingView.superview?.convert(presentingView.frame, to: presentingView.window) ?? presentingView.frame 26 | let containerWidth = presentingView.window?.frame.width ?? presentingView.superview?.frame.width ?? 0 27 | var backgroundXPosition = (backgroundSize.width - handleSize.width) / 2 * -1 28 | let distanceToLeadingEdge = frameInWindow.minX + backgroundXPosition 29 | let distanceToTrailingEdge = containerWidth - frameInWindow.minX - backgroundXPosition - backgroundSize.width 30 | if distanceToLeadingEdge < 0 { 31 | backgroundXPosition = toolPickerBackgroundView.shadowBlur * -1 32 | } 33 | if distanceToTrailingEdge < 0 { 34 | backgroundXPosition = (backgroundSize.width - handleSize.width - toolPickerBackgroundView.shadowBlur) * -1 35 | } 36 | return layout(forPresenting: toolPickerView, 37 | and: toolPickerBackgroundView, 38 | from: presentingView, 39 | handleXPosition: backgroundXPosition * -1, 40 | backgroundXPosition: backgroundXPosition, 41 | isReverse: false) 42 | } 43 | 44 | static func multipleToolsLayout(forPresenting toolPickerView: KeyboardToolPickerView, 45 | and toolPickerBackgroundView: KeyboardToolPickerBackgroundView, 46 | from presentingView: UIView) -> KeyboardToolPickerLayout { 47 | let handleSize = handleSize(from: presentingView) 48 | let backgroundSize = toolPickerBackgroundView.preferredSize(containingContentWidth: toolPickerView.intrinsicContentSize.width) 49 | let direction = direction(forPresentingViewOfWidth: backgroundSize.width, from: presentingView) 50 | let frameInWindow = presentingView.superview?.convert(presentingView.frame, to: presentingView.window) ?? presentingView.frame 51 | let containerWidth = presentingView.window?.frame.width ?? presentingView.superview?.frame.width ?? 0 52 | var backgroundXPosition: CGFloat 53 | switch direction { 54 | case .left: 55 | backgroundXPosition = backgroundSize.width * -1 + handleSize.width + toolPickerView.leadingSpacing + toolPickerBackgroundView.shadowBlur 56 | let distanceToLeftEdge = frameInWindow.minX + backgroundXPosition 57 | if distanceToLeftEdge < 0 { 58 | backgroundXPosition -= distanceToLeftEdge 59 | } 60 | case .right: 61 | backgroundXPosition = (toolPickerView.leadingSpacing + toolPickerBackgroundView.shadowBlur) * -1 62 | let distanceToRightEdge = containerWidth - frameInWindow.minX - backgroundXPosition - backgroundSize.width 63 | if distanceToRightEdge < 0 { 64 | backgroundXPosition += distanceToRightEdge 65 | } 66 | } 67 | return layout(forPresenting: toolPickerView, 68 | and: toolPickerBackgroundView, 69 | from: presentingView, 70 | handleXPosition: backgroundXPosition * -1, 71 | backgroundXPosition: backgroundXPosition, 72 | isReverse: direction == .left) 73 | } 74 | } 75 | 76 | private extension KeyboardToolPickerFrameCalculator { 77 | private static func layout(forPresenting toolPickerView: KeyboardToolPickerView, 78 | and toolPickerBackgroundView: KeyboardToolPickerBackgroundView, 79 | from presentingView: UIView, 80 | handleXPosition: CGFloat, 81 | backgroundXPosition: CGFloat, 82 | isReverse: Bool) -> KeyboardToolPickerLayout { 83 | let toolPickerSize = toolPickerView.intrinsicContentSize 84 | let plateHeight = toolPickerBackgroundView.plateHeight 85 | let backgroundSize = toolPickerBackgroundView.preferredSize(containingContentWidth: toolPickerSize.width) 86 | let backgroundShadowBlur = toolPickerBackgroundView.shadowBlur 87 | let backgroundYPosition = (backgroundSize.height - presentingView.frame.height) * -1 + backgroundShadowBlur 88 | let backgroundOrigin = CGPoint(x: backgroundXPosition, y: backgroundYPosition) 89 | let backgroundFrame = CGRect(origin: backgroundOrigin, size: backgroundSize) 90 | let pickerXPosition = backgroundXPosition + backgroundShadowBlur 91 | let pickerYPosition = backgroundYPosition + (plateHeight - toolPickerSize.height) / 2 + backgroundShadowBlur 92 | let pickerOrigin = CGPoint(x: pickerXPosition, y: pickerYPosition) 93 | let pickerFrame = CGRect(origin: pickerOrigin, size: toolPickerSize) 94 | return KeyboardToolPickerLayout(backgroundFrame: backgroundFrame, 95 | pickerFrame: pickerFrame, 96 | handleXPosition: handleXPosition, 97 | isReverse: isReverse) 98 | } 99 | 100 | private static func direction(forPresentingViewOfWidth width: CGFloat, from presentingView: UIView) -> KeyboardToolPickerDirection { 101 | let containerWidth = presentingView.window?.frame.width ?? presentingView.superview?.frame.width ?? 0 102 | let frameInWindow = presentingView.superview?.convert(presentingView.frame, to: presentingView.window) ?? presentingView.frame 103 | let distanceToLeft = frameInWindow.minX 104 | let distanceToRight = containerWidth - frameInWindow.minX 105 | if distanceToLeft >= width { 106 | return .left 107 | } else if distanceToRight >= width { 108 | return .right 109 | } else if distanceToLeft >= distanceToRight { 110 | return .left 111 | } else { 112 | return .right 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Views/KeyboardToolPickerView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class KeyboardToolPickerView: UIView { 4 | var leadingSpacing: CGFloat = 0 { 5 | didSet { 6 | if trailingSpacing != oldValue { 7 | invalidateIntrinsicContentSize() 8 | setNeedsLayout() 9 | } 10 | } 11 | } 12 | var trailingSpacing: CGFloat = 0 { 13 | didSet { 14 | if trailingSpacing != oldValue { 15 | invalidateIntrinsicContentSize() 16 | setNeedsLayout() 17 | } 18 | } 19 | } 20 | var toolSize = CGSize(width: 25, height: 25) { 21 | didSet { 22 | if toolSize != oldValue { 23 | invalidateIntrinsicContentSize() 24 | setNeedsLayout() 25 | } 26 | } 27 | } 28 | var toolSpacing: CGFloat = 6 { 29 | didSet { 30 | if toolSpacing != oldValue { 31 | invalidateIntrinsicContentSize() 32 | setNeedsLayout() 33 | } 34 | } 35 | } 36 | private(set) var highlightedIndex: Int? 37 | var showReversed = false { 38 | didSet { 39 | if showReversed != oldValue { 40 | setNeedsLayout() 41 | } 42 | } 43 | } 44 | var toolCount: Int { 45 | return displayRepresentations.count 46 | } 47 | override var intrinsicContentSize: CGSize { 48 | let totalViewWidth = CGFloat(displayRepresentations.count) * toolSize.width 49 | let totalSpacing = max(CGFloat(displayRepresentations.count - 1), 0) * toolSpacing 50 | let width = leadingSpacing + trailingSpacing + totalViewWidth + totalSpacing 51 | return CGSize(width: width, height: toolSize.height) 52 | } 53 | 54 | private var toolViews: [KeyboardToolView] = [] 55 | private var displayRepresentations: [KeyboardToolDisplayRepresentation] = [] 56 | private let highlightBackgroundView: UIView = { 57 | let view = UIView() 58 | view.layer.cornerRadius = 5 59 | view.isHidden = true 60 | return view 61 | }() 62 | 63 | override init(frame: CGRect) { 64 | super.init(frame: frame) 65 | highlightBackgroundView.backgroundColor = .systemBlue 66 | addSubview(highlightBackgroundView) 67 | } 68 | 69 | required init?(coder: NSCoder) { 70 | fatalError("init(coder:) has not been implemented") 71 | } 72 | 73 | override func layoutSubviews() { 74 | super.layoutSubviews() 75 | layoutToolViews() 76 | layoutHighlightBackgroundView() 77 | } 78 | 79 | func show(_ displayRepresentations: [KeyboardToolDisplayRepresentation], atSize contentSize: KeyboardToolContentSize) { 80 | self.displayRepresentations = displayRepresentations 81 | highlightedIndex = nil 82 | removeToolViews() 83 | addToolViews(ofSize: contentSize) 84 | } 85 | 86 | func highlightTool(closestTo location: CGPoint) { 87 | if let index = indexOfTool(closestTo: location) { 88 | highlightTool(at: index) 89 | } 90 | } 91 | 92 | func highlightTool(at index: Int) { 93 | highlightedIndex = index 94 | highlightBackgroundView.isHidden = highlightedIndex == nil 95 | layoutHighlightBackgroundView() 96 | toolViews[index].isHighlighted = true 97 | } 98 | 99 | func clearHighlightedTool() { 100 | highlightedIndex = nil 101 | highlightBackgroundView.isHidden = true 102 | } 103 | } 104 | 105 | private extension KeyboardToolPickerView { 106 | private func removeToolViews() { 107 | for toolView in toolViews { 108 | toolView.removeFromSuperview() 109 | } 110 | toolViews = [] 111 | } 112 | 113 | private func addToolViews(ofSize contentSize: KeyboardToolContentSize) { 114 | for toolDisplay in displayRepresentations { 115 | let toolView = KeyboardToolView() 116 | toolView.foregroundColor = .keyboardToolForeground 117 | toolView.highlightedForegroundColor = .keyboardToolForegroundHighlighted 118 | toolView.show(toolDisplay, atSize: contentSize) 119 | addSubview(toolView) 120 | toolViews.append(toolView) 121 | } 122 | } 123 | 124 | private func layoutToolViews() { 125 | for (idx, toolView) in toolViews.enumerated() { 126 | toolView.frame = frameForTool(at: idx) 127 | } 128 | } 129 | 130 | private func layoutHighlightBackgroundView() { 131 | if let highlightedIndex = highlightedIndex { 132 | highlightBackgroundView.frame = frameForTool(at: highlightedIndex) 133 | } 134 | } 135 | 136 | private func frameForTool(at index: Int) -> CGRect { 137 | let adjustedIndex = showReversed ? displayRepresentations.count - index - 1 : index 138 | let xPosition = leadingSpacing + CGFloat(adjustedIndex) * (toolSize.width + toolSpacing) 139 | let yPosition = (bounds.height - toolSize.height) / 2 140 | let origin = CGPoint(x: xPosition, y: yPosition) 141 | return CGRect(origin: origin, size: toolSize) 142 | } 143 | 144 | private func indexOfTool(closestTo location: CGPoint) -> Int? { 145 | var candidateIndex: Int? 146 | var candidateDistance: CGFloat = .greatestFiniteMagnitude 147 | for (idx, toolView) in toolViews.enumerated() { 148 | toolView.isHighlighted = false 149 | let distance = abs(toolView.frame.midX - location.x) 150 | if distance < candidateDistance { 151 | candidateIndex = idx 152 | candidateDistance = distance 153 | } 154 | } 155 | return candidateIndex 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/KeyboardToolbar/Views/KeyboardToolView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class KeyboardToolView: UIView { 4 | var foregroundColor: UIColor? { 5 | didSet { 6 | if foregroundColor != oldValue { 7 | updateForegroundColor() 8 | } 9 | } 10 | } 11 | var highlightedForegroundColor: UIColor? { 12 | didSet { 13 | if foregroundColor != oldValue { 14 | updateForegroundColor() 15 | } 16 | } 17 | } 18 | var isHighlighted = false { 19 | didSet { 20 | if isHighlighted != oldValue { 21 | updateForegroundColor() 22 | } 23 | } 24 | } 25 | 26 | private let titleLabel: UILabel = { 27 | let view = UILabel() 28 | view.translatesAutoresizingMaskIntoConstraints = false 29 | view.textAlignment = .center 30 | return view 31 | }() 32 | private let imageView: UIImageView = { 33 | let view = UIImageView() 34 | view.translatesAutoresizingMaskIntoConstraints = false 35 | view.contentMode = .center 36 | return view 37 | }() 38 | 39 | private var offset: CGPoint = .zero 40 | 41 | override func layoutSubviews() { 42 | super.layoutSubviews() 43 | let size = CGSize(width: bounds.size.width - offset.x, height: bounds.size.height - offset.y) 44 | titleLabel.frame = CGRect(origin: offset, size: size) 45 | imageView.frame = CGRect(origin: offset, size: size) 46 | } 47 | 48 | func show(_ displayRepresentation: KeyboardToolDisplayRepresentation, atSize size: KeyboardToolContentSize) { 49 | prepareForReuse() 50 | switch displayRepresentation { 51 | case .text(let configuration): 52 | offset = configuration.offset 53 | titleLabel.text = configuration.text 54 | titleLabel.font = configuration.font(ofSize: size) 55 | addSubview(titleLabel) 56 | case .image(let configuration): 57 | offset = .zero 58 | imageView.image = configuration.image(ofSize: size) 59 | addSubview(imageView) 60 | case .symbol(let configuration): 61 | offset = .zero 62 | imageView.image = configuration.image(ofSize: size) 63 | addSubview(imageView) 64 | } 65 | } 66 | } 67 | 68 | private extension KeyboardToolView { 69 | private func prepareForReuse() { 70 | titleLabel.text = nil 71 | imageView.image = nil 72 | titleLabel.removeFromSuperview() 73 | imageView.removeFromSuperview() 74 | } 75 | 76 | private func updateForegroundColor() { 77 | if isHighlighted { 78 | titleLabel.textColor = highlightedForegroundColor 79 | imageView.tintColor = highlightedForegroundColor 80 | } else { 81 | titleLabel.textColor = foregroundColor 82 | imageView.tintColor = foregroundColor 83 | } 84 | } 85 | } 86 | --------------------------------------------------------------------------------