Introduction to Swift Keypaths

Handling Settings

released Fri, 29 Mar 2019
Swift Version 5.0

Handling the Settings

The next part is crucial. What do we intend to do with these settings? The natural way would be to build a UI that displays them and allows the user to edit them. Another option would be to set settings to specific values. All of these things are possible. We will do something slightly simpler which still explains the basics of the code without requiring us to write a lot of UI code. In this example, we will iterate over the settings, print out their title and value, and will then change the value to true if it is Bool. However, as mentioned above, you can use the very same pattern to display these settings in a very nice UI.

The first thing we need is a function we can call with our settings. This function needs to be generic. We should be able to call it with any type. To do this, it will only have one argument of the SettingsProvider type. However, later on, we will also need the specific type that implements the protocol, which is why we code this in a generic manner:

func editSettings<Provider: SettingsProvider>(provider: Provider) {

   ...

}



/// And lets call it

let appSettings = Settings()

editSettings(appSettings)

Since our SettingsProvider only really offers one property, the settingsEntries we will iterate over them:

func editSettings<Provider: SettingsProvider>(provider: Provider) {

   for setting in provider.settingsEntries {

     

   }

}

Remember how we created settings entries for nested settings, such as ProfileSettings as well as the actual settings values, such as PrivacySettings.passcode? In this case, we have to disambigiuate, do we have an actual value that we want to print and edit, or do we have another, nested, settings provider? To do this, we will get the value of the current KeyPath from the Provider:

func editSettings<Provider: SettingsProvider>(provider: Provider) {

   for setting in provider.settingsEntries {

     let value = provider[keyPath: setting.keyPath]

   }

}

Here, we tell Swift to give us the value in the current SettingsProvider at the KeyPath setting.keypath. This doesn't really solve our problem, though. This value could still be a Bool type or a PrivacySettings type. We can't check whether the type is PrivacySettings because we want to be generic, work with any type. However, since all nested settings also have to implement the SettingsProvider protocol, we can simply test for this:

func editSettings<Provider: SettingsProvider>(provider: Provider) {

   for setting in provider.settingsEntries {

     let value = provider[keyPath: setting.keyPath]

     if let nestedProvider = value as? SettingsProvider {

     }

   }

}

Via the value as? SettingsProvider we're just asking Swift at runtime whether the value is actually a type we want to handle (such as Bool, or String) or another nested SettingsProvider that we'd like to iterate over. Which is precisely what we will do next, iterate over the provider again. However, since we may have another settings provider, and then another one, we would need to write more and more for loops:

   for setting in provider.settingsEntries {

     let value = provider[keyPath: setting.keyPath]

     if let nestedProvider = value as? SettingsProvider {

       for nestedSetting in nestedProvider.settingsEntries {

         let value = provider[keyPath: nestedSetting.keyPath]

         if let nestedNestedProvider = value as? SettingsProvider {

           for nestedNestedSetting in nestedNestedProvider.settingsEntries {

           ...

           }

         }

       }

     }

   }

This is truly terrible. Instead, we will move this iteration code into a inlined function updateSetting that can be called recursively. So, whenever we identify another nested provider, we will simply call the function again:

func editSettings<Provider: SettingsProvider>(provider: Provider) {

   // All subsequent iterations happen here

   func updateSetting(keyPath: AnyKeyPath, title: String) {

     let value = provider[keyPath: keyPath]

     if let nestedProvider = value as? SettingsProvider {

       for item in nestedProvider.settings {

         // ??

       }

     }

   }



   // The initial iteration

   for setting in provider.settingsEntries {

     updateSetting(keyPath: setting.keyPath, title: setting.title)

   }

}

Here, we moved the iteration code into its own function. It has two parameters, the keyPath of the value we want to test, and the title of the current setting. The keypath helps us to extract the value:

let value = provider[keyPath: keyPath]

The value is then tested for being another SettingsProvider:

if let nestedProvider = value as? SettingsProvider {

  ...

}

But what do we do now? In the first step, here, the keyPath would be \Settings.profileSettings and the value will be ProfileSettings. But what do we do now? If we iterate over the ProfileSettings as a SettingsProvider we get two new SettingsEntries, one for displayName, and one for shareUpdates. However, our updateSetting function always calls let value = provider[keyPath: keyPath] on the original provider, the Settings class that was given as a parameter to the editSettings function. This makes sense, because we want to edit the contents of this Settings type.

So we have a keypath to \Setting.profileSettings and a keypath to \ProfileSettings.displayName and we want to retrieve the value at \Setting.profileSettings.displayName. We can use Swift's KeyPath composition!

func editSettings<Provider: SettingsProvider>(provider: Provider) {

   // All subsequent iterations happen here

   func updateSetting(keyPath: AnyKeyPath, title: String) {

     let value = provider[keyPath: keyPath]

     if let nestedProvider = value as? SettingsProvider {

       for item in nestedProvider.settings {

         // Join the keypaths

         if let joined = keyPath.appending(path: item.keyPath) {

           updateSetting(keyPath: joined, title: item.title)

         }

       }

     }

   }



   // The initial iteration

   for setting in provider.settingsEntries {

     updateSetting(keyPath: setting.keyPath, title: setting.title)

   }

}

In the code above, the magic happens in the following three lines:

if let joined = keyPath.appending(path: item.keyPath) {

   updateSetting(keyPath: joined, title: item.title)

}

We take the original keyPath that was given to the updateSettings function first (i.e. \Setting.profileSettings) and we take the item.keyPath, which is the keypath of the current item (i.e. \ProfileSettings.displayName) and join them to \Setting.profileSettings.displayName. Now we can use this joined keypath to retrieve the value of the displayName property of the provider instance and perform another iteration. By implementing it this way, we can easily support more nesting hierachies.

So what happens when our value isn't another nested SettingsProvider but an actual value such as String or Bool (displayName or shareUpdates). Since we want to be able to change the value that is stored here (from false to true) we do a run-time cast from this keyPath to a WritableKeyPath to figure out if we have an editable value.

if let writableKeypath = keyPath as? WritableKeyPath<???, ???> {

}

However, WritableKeyPath needs two types, the Root and the Value, what do we insert here? We don't know the type of the Root as we're iterating over Settings, ProfileSettings, PrivacySettings, etc, right? It could be anything. Actually, we do know the type of Root. Since our keypaths are joined (\Settings.profileSettings.displayName) our root is always Settings. So we could write WritableKeyPath<Settings, ???> but now our function would not be generic anymore. If we look at the header of our original function, though, we see something interesting:

func editSettings<Provider: SettingsProvider>(provider: Provider) {

...

We actually do have our root type, as the Provider generic type to the editSettings function. So we can just write WritableKeyPath<Provider, ???>. The second type of our WritableKeyPath is also easy. If we want to edit boolean flags, it is Bool, and if we want to edit Strings it is .. well, String. Lets type this out:

func editSettings<Provider: SettingsProvider>(provider: Provider) {

   // All subsequent iterations happen here

   func updateSetting(keyPath: AnyKeyPath, title: String) {

     let value = provider[keyPath: keyPath]

     if let nestedProvider = value as? SettingsProvider {

       for item in nestedProvider.settings {

         if let joined = keyPath.appending(path: item.keyPath) {

           updateSetting(keyPath: joined, title: item.title)

         }

       }

     } else if let writable = keyPath as? WritableKeyPath<Provider, Bool> {

       print(title)

       provider[keyPath: writable] = true

     }

   }



   // The initial iteration

   for setting in provider.settingsEntries {

     updateSetting(keyPath: setting.keyPath, title: setting.title)

   }

}

That's it! We cast the keypath to a writable variant, and then we can modify the contents of our Settings type (or nested types). Everything happens in these additional lines of code:

if let writable = keyPath as? WritableKeyPath<Provider, Bool> {

       print(title)

       provider[keyPath: writable] = true

     }

}

Subsequently, we could easily extend these lines to also query for WritableKeyPath<Provider, String> or WritableKeyPath<Provider, Double>, etc.

This is our final function. It allows us to handle app settings in a completely generic manner. It iterates over them, it can display them (we did not see that because the code size would have exploded), and it can edit them. Without ever knowing the actual shape of the settings type. It can also be used for structures other than settings. Any complex type can be iterated in this manner.

However, this was only a small example of what you can do with keypaths. There is even more! Lets have a look.