Categories
Swift SwiftUI Xcode

LibraryContentProvider in Xcode

LibraryContentProvider protocol in Xcode is a way for adding custom views and view modifiers to the Xcode library, which enables drag and dropping them to the SwiftUI preview. Xcode automatically looks for types implementing the protocol and then adds items to the library. It takes so little code that it makes sense to do it if you are using the library for building views.

As an example, we’ll add a custom SwiftUI view SubtitledButton to the library.

struct SubtitledButton: View {
let title: LocalizedStringKey
let subtitle: LocalizedStringKey
let action: () -> Void
var body: some View {
Button(action: action, label: {
VStack(spacing: 4) {
Text(title)
Text(subtitle)
.font(.footnote)
}
})
}
}

The only thing we need to do is creating a new type which conforms to LibraryContentProvider and then returning the button as a LibraryItem.

struct LibraryContent: LibraryContentProvider {
var views: [LibraryItem] {
return [
LibraryItem(SubtitledButton(title: "Title",
subtitle: "Subtitle",
action: {}),
title: "SubtitledButton",
category: .control)
]
}
}

After adding the new type, Xcode updates the library automatically.

Xcode library view with SubtitledButton showing up.
Xcode library with SubtitledButton.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift SwiftUI UIKit

UIKit navigation with SwiftUI views

Recently I was asked a question about creating an app which has SwiftUI views but no navigation logic in it. Instead, UIKit controls how views are presented. It is a fair question since SwiftUI views have navigation support, but not everything is there if we need to support previous iOS versions as well, or we have a case of an app which have both UIKit and SwiftUI views. Therefore, let’s take a look at on one approach, how to handle navigation on the UIKit side but still use SwiftUI views.

UIHostingController presenting SwiftUI view

SwiftUI views are presented in UIKit views with UIHostingController which just takes in the SwiftUI view. UIHostingController is a UIViewController subclass, therefore it can be used like any other view controller in the view hierarchy. For getting things started, let’s configure SceneDelegate to use an object named FlowCoordinator which will handle navigation logic and then ask it to present a simple SwiftUI view.

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private lazy var flowController = FlowCoordinator(window: window!)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
flowController.showRootView()
window?.makeKeyAndVisible()
}
}
final class FlowCoordinator {
private let window: UIWindow
init(window: UIWindow) {
self.window = window
}
func showRootView() {
let swiftUIView = ContentView()
let hostingView = UIHostingController(rootView: swiftUIView)
window.rootViewController = UINavigationController(rootViewController: hostingView)
}
}
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, World!")
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.green)
}
}
A simple iOS app showing a green view which is rendered with SwiftUI but presented with UIKit
A simple app showing a green view which is rendered with SwiftUI but presented with UIKit.

Inserting the FlowCoordinator into SwiftUI view

The next step is that we want to allow SwiftUI view to control what is presented on the screen. For example, let’s add a button to the SwiftUI view which should present a sheet with another SwiftUI view. The button action needs to be able to talk to the flow coordinator, which controls what is presented on the screen. One way to insert the FlowCoordinator into SwiftUI environment is by conforming to ObservableObject and using the environmentObject() view modifier. Alternative is using EnvironmentValues and defining a key. For more information please check Injecting dependencies using environment values and keys in SwiftUI.

final class FlowCoordinator: ObservableObject {}
let swiftUIView = ContentView()
.environmentObject(self) // self is FlowCoordinator
struct ContentView: View {
@EnvironmentObject var flowController: FlowCoordinator
var body: some View {
VStack(spacing: 8) {
Text("Root view")
Button("Present Sheet", action: flowController.showDetailView)

Presenting a sheet

The sheet presentation code goes into the FlowCoordinator and as an example we show a DetailView which has a button for dismissing itself. Yet again, SwiftUI view just passes the handling to the FlowCoordinator.

final class FlowCoordinator: ObservableObject {
//
func showDetailView() {
let detailView = DetailView()
.environmentObject(self)
let viewController = UIHostingController(rootView: detailView)
window.rootViewController?.present(viewController, animated: true, completion: nil)
}
func closeDetailView() {
// Needs to be more sophisticated later when there are more views
window.rootViewController?.presentedViewController?.dismiss(animated: true, completion: nil)
}
}
struct DetailView: View {
@EnvironmentObject var flowController: FlowCoordinator
var body: some View {
VStack(spacing: 8) {
Text("Detail view content")
Button("Dismiss", action: flowController.closeDetailView)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.orange)
}
}
A simple iOS app showing an orange sheet which is rendered with SwiftUI but presented with UIKit
A simple app showing an orange sheet which is rendered with SwiftUI but presented with UIKit.

Summary

We created a simple sample app which uses UIKit navigation logic but renders views with SwiftUI views. This kind of setup could be useful for apps which mix UIKit and SwiftUI. But I believe that even in case of that we could still use SwiftUI navigation in sub-flows but could keep using this approach for handling root view navigation.

Example Project

UIKitNavigationWithSwiftUIViews (Xcode 13.2.1)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift SwiftUI UIKit

Using a multi component picker in a SwiftUI form

SwiftUI has a Picker view available with multiple different styles. One example of when it falls short is when we want to use a multi component picker with wheel style. One way how to try to achieve this is using a HStack with two Picker views, but it does not work very well, especially when trying to show it inside a Form view. So what else we can do? If something can’t be done in SwiftUI then we can use UIKit instead.

In my case, I wanted to create a picker which allows picking a date duration. It would have one wheel for selecting a number and the other wheel for selecting either days, weeks or months.

Screenshot of a SwiftUI form with a two component wheel picker where left wheel selects a number and right wheel selects days, weeks or months.

Firstly, let’s create a tiny struct which is going to hold the state of this picker. It needs to store a numeric value and the unit: days, weeks, months. Let’s name it as DateDuration. Since we want to iterate over the DateDuration.Unit, we’ll conform it to CaseIterable protocol.

struct DateDuration {
let value: Int
let unit: Unit
enum Unit: String, CaseIterable {
case days, weeks, months
}
}

UIPickerView in UIKit can do everything we want, therefore we’ll need to wrap it into a SwiftUI view. This can be done by creating a new type which conforms to UIViewRepresentable protocol. Also, we need a binding which holds the value of the current selection: when the user changes it, the binding communicates the changes back and vice-versa. Additionally, we’ll add properties for configuring values and units. UIPickerView us created and configured in the makeUIView(context:) function. UIPickerView is driven by a data source and a delegate, which means we require a coordinator object as well. Coordinator is part of the UIViewRepresentable protocol.

struct DateDurationPicker: UIViewRepresentable {
let selection: Binding<DateDuration>
let values: [Int]
let units: [DateDuration.Unit]
func makeUIView(context: Context) -> UIPickerView {
let pickerView = UIPickerView(frame: .zero)
pickerView.translatesAutoresizingMaskIntoConstraints = false
pickerView.delegate = context.coordinator
pickerView.dataSource = context.coordinator
return pickerView
}
//
}

Coordinator is created in the makeCoordinator() function. It is going to do most of the work by providing data to the UIPickerView and handling the current selection. Therefore, we’ll store the selection binding, values, and units in the Coordinator class as well.

struct DateDurationPicker: UIViewRepresentable {
//
func makeCoordinator() -> Coordinator {
return Coordinator(selection: selection, values: values, units: units)
}
final class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
let selection: Binding<DateDuration>
let values: [Int]
let units: [DateDuration.Unit]
init(selection: Binding<DateDuration>, values: [Int], units: [DateDuration.Unit]) {
self.selection = selection
self.values = values
self.units = units
}
//
}
}

The last missing piece is implementing UIPickerViewDataSource and UIPickerViewDelegate methods in the Coordinator class. This is pretty straight-forward to do. We’ll need to display two components where the first component is the list of values and the second component is the unit: days, weeks, months. When the user selects a new value, we’ll change the DateDuration value of the binding.

func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 2
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
return component == 0 ? values.count : units.count
}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
if component == 0 {
return "\(values[row])"
}
else {
return units[row].rawValue
}
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
let valueIndex = pickerView.selectedRow(inComponent: 0)
let unitIndex = pickerView.selectedRow(inComponent: 1)
selection.wrappedValue = DateDuration(value: values[valueIndex], unit: units[unitIndex])
}

Finally, let’s hook it up in an example view.

struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
Form {
Section {
//
DateDurationPicker(
selection: $viewModel.selection,
values: Array(1..<100),
units: DateDuration.Unit.allCases
)
//
}
}
.navigationTitle("Reminders")
}
}
}
extension ContentView {
final class ViewModel: ObservableObject {
@Published var selection = DateDuration(value: 1, unit: .days)
//
}
}

Example Project

SwiftUIDateDurationPicker (GitHub, Xcode 13.2.1)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Swift SwiftUI

Gradient text in SwiftUI

If we want to spice up the user interface, then we can make some titles in the app to use gradient colours. In WWDC’21 Apple introduced an API for making gradient text styles easy to create. The .foregroundStyle() view modifier takes in a type which conforms to ShapeStyle protocol. One of these types are gradient types in SwiftUI. Therefore, creating a fun gradient text is a matter of creating a text and applying a foregroundStyle view modifier with a gradient on it.

let gradientColors: [Color] = [.purple, .blue, .cyan, .green, .yellow, .orange, .red]
Text("Hello, world!")
.font(.system(size: 60))
.foregroundStyle(
.linearGradient(
colors: gradientColors,
startPoint: .leading,
endPoint: .trailing
)
)
Text("Hello, world!")
.font(.system(size: 60))
.foregroundStyle(
.ellipticalGradient(
colors: gradientColors
)
)
Text("Hello, world!")
.font(.system(size: 60))
.foregroundStyle(
.conicGradient(
colors: gradientColors,
center: .center
)
)
Text("Hello, world!")
.font(.system(size: 60))
.foregroundStyle(
.radialGradient(
colors: gradientColors,
center: .center,
startRadius: 50,
endRadius: 100
)
)
view raw Text.swift hosted with ❤ by GitHub

The new view modifier is great, but it is iOS 15+, macOS 12.0+. Another way for achieving this result is recreating it ourselves. This involves in overlaying the text with gradient and then masking it with the text.

Text("Hello, world!")
.font(.system(size: 60))
.myForegroundStyle(
LinearGradient(
colors: gradientColors,
startPoint: .leading,
endPoint: .trailing
)
)
.padding()
.background(.black)
extension View {
func myForegroundStyle<Content: View>(_ content: Content) -> some View {
self.overlay(content).mask(self)
}
}
view raw Mask.swift hosted with ❤ by GitHub

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS macOS Swift SwiftUI

Focusing views in SwiftUI

WWDC’21 introduced APIs for managing focus in SwiftUI. When dealing with multiple focusable views, we can create an enum for representing these and use the new @FocusState property wrapper along with focused() view modifier, where each of the focusable view binds its focus to an enum case. This can look like this:

struct ContentView: View {
@StateObject var viewModel = ViewModel()
enum FormEntry: Hashable {
case lastName, firstName, email
}
@FocusState var focused: FormEntry?
var body: some View {
VStack {
Text("Application")
TextField("Last Name", text: $viewModel.lastName, prompt: nil)
.focused($focused, equals: .lastName)
TextField("First Name", text: $viewModel.firstName, prompt: nil)
.focused($focused, equals: .firstName)
TextField("Email", text: $viewModel.email, prompt: nil)
.focused($focused, equals: .email)
Button("Reset") {
viewModel.reset()
focused = .lastName
}
}
.frame(idealWidth: 600)
.padding()
}
}

In this example we have FormEntry enum which represents focusable elements in the view. Each of the view has a focused() view modifier, which binds the current focus state to the text field if it matches with the enum case. Meaning, setting focused property to FormEntry.email will move the focus to the email text field. It works the other way around as well. If we tap on the email text field, SwiftUI will set the focused property value to FormEntry.email.

Sometimes we just want to control the focus only once. Let’s take an example use case where we enter an order id and after tapping a search button we would like to remove the focus from the order id text field. In this case, we can just use a boolean property and bind the focus state boolean property to the text field’s focused() view modifier. Setting the property value to false in the button action will tell SwiftUI to resign focus from the text field.

struct ContentView: View {
@StateObject var viewModel = ViewModel()
@FocusState var isOrderIDFocused: Bool
var body: some View {
VStack {
Text("Orders")
TextField("Order ID", text: $viewModel.orderId, prompt: nil)
.focused($isOrderIDFocused)
Button("Search") {
viewModel.search()
isOrderIDFocused = false
}
}
.padding()
}
}

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift SwiftUI

Tab view in SwiftUI

TabView is a container view which enables navigating between multiple flows by selecting one of the items on the tab bar. Tapping on a tab item replaces the visible view with a view associated with the item. Tab view is set up by creating views which have tab items attached. Tab items are created with the tabItem() view modifier, which supports setting a text and an image. In addition, there is a badge() view modifier if we would like to show a badge on top of the item.

Tab view also supports selection handling. Selection handling is needed when we need to programmatically control which tab is selected. For that, we’ll need to choose a type which represents the selection. The only requirement is that the type is Hashable. Therefore, we can use an enum with raw values and have a clear and readable representation of tabs. Next, all the views managed the tab view need to have a tag set with one of the enum cases. Then we can create a binding with the selection type and pass it into TabView and SwiftUI will select the tab view item which has a tag equal to the selection. Just to reiterate that we can use any other type for representing the selection, as long as it conforms to Hashable. Could be just integers as well.

struct ContentView: View {
@State private var selection: Tab = .schedule
enum Tab: Int {
case conversations, schedule, settings
}
var body: some View {
TabView(selection: $selection) {
ConversationListView()
.badge(1)
.tabItem {
Image(systemName: "bubble.left.and.bubble.right.fill")
Text("Messages")
}
.tag(Tab.conversations)
ScheduleView()
.tabItem {
Image(systemName: "clock.fill")
Text("Schedule")
}
.tag(Tab.schedule)
SettingsView()
.tabItem {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.tag(Tab.settings)
}
}
}
view raw TabView.swift hosted with ❤ by GitHub
iOSsimulator with an app which shows tab view with 3 items: messages with a badge showing 1, schedule, and settings.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS macOS Swift SwiftUI

Structuring platform specific code in SwiftUI

Xcode provides a cross-platform app template in SwiftUI which can be built for macOS and iOS. This template includes a Shared folder which is meant for code used both on macOS and iOS. Many SwiftUI types and functions work both on macOS and iOS but along with developing an app we’ll encounter cases where we need to specialize per platform. When I was building a multiplatform app then the very first thing I encountered was list styles. On macOS I wanted to use a different list style than on iOS (SidebarListStyle vs GroupedListStyle). Therefore, let’s take this as an example and see what are the ways how to handle this.

Compile time checks

The first way is to add compile time checks directly to the UI code. Swift provides #if os() for branching out code which should only be compiled on the specified platform. One downside of this approach is that it can make the code pretty long in the view body when we have multiple platform specific branches. In those cases, it might make sense to have separate functions which just contain the #if os() branch. But in the example below, we just have a listStyle view modifier, which is configured differently on macOS and iOS.

struct ContentView: View {
@State var items = [1, 2, 3]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
}
}
#if os(macOS)
.listStyle(SidebarListStyle())
#else
.listStyle(GroupedListStyle())
#endif
}
}

Platform specific extensions

Another way for handling platform specific code is to create a separate files which are only included to platform targets. In this concrete case, those files contain a view extension and define a contentListStyle property which returns a list style. ContentView.swift file can access the property and when we are building for macOS then ContentView+Mac.swift is compiled and when building for iOS then ContentView+iOS.swift. I would recommend creating separate folders for platform specific code. For example, the project folders could be Shared, Mac, and iOS. Separate folders give a clear overview where the platform specific code is.

struct ContentView: View {
@State var items = [1, 2, 3]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text("\(item)")
}
}
.listStyle(contentListStyle)
}
}
extension ContentView {
var contentListStyle: GroupedListStyle {
return GroupedListStyle()
}
}
extension ContentView {
var contentListStyle: SidebarListStyle {
return SidebarListStyle()
}
}
Project structure in Xcode with Shared, Mac, and iOS folders for managing platform specific code.
Project structure with Shared, Mac, and iOS folders.

Summary

SwiftUI code can be shared most of the time between platforms. Sometimes we’ll need to configure the shared code per platform or use platform specific APIs. Hopefully those two approaches will give some flexibility on handling such code.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
macOS Swift SwiftUI

Sidebar layout on macOS in SwiftUI

A common UI layout on macOS has a sidebar and detail view side by side. The sidebar contains a list of items, where the selected item is displayed on the right and displays details of it. One would expect that creating such a view hierarchy in SwiftUI should be pretty easy to set up. In this post, we’ll take a look at how to create a basic view with sidebar which supports selection.

Building the layout

We’ll build a simple sample app which shows a list of fruits in the sidebar and when clicking on any of the fruits, the right pane displays the name of the fruit. Therefore, we’ll need a struct representing a fruit, a view model for storing the list of fruits, and a property for storing the selected fruit in the sidebar.

struct Fruit: Identifiable {
let id = UUID().uuidString
let name: String
}
final class ViewModel: ObservableObject {
init(fruits: [Fruit] = ViewModel.defaultFruits) {
self.fruits = fruits
self.selectedId = fruits[1].id
}
@Published var fruits: [Fruit]
@Published var selectedId: String?
static let defaultFruits: [Fruit] = ["Apple", "Orange", "Pear"].map({ Fruit(name: $0) })
}

We can create the layout with NavigationView and NavigationLink. Inside the NavigationView we’ll first add a List where each of the item in the list is represented by a NavigationLink. One of the NavigationLink initializers takes a title, tag, and selection binding. Tag is used for identifying items in the list and setting one of the tag values to the selection binding will make the sidebar to select the item programmatically. Also, we’ll need to set the list style to “sidebar” which adds the appropriate styling to it. Finally, we’ll add a Text element, which acts as a placeholder view when there is no selection. And that is all what we need to do to get going with a view with sidebar and detail pane.

struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
List {
ForEach(viewModel.fruits) { item in
NavigationLink(item.name, tag: item.id, selection: $viewModel.selectedId) {
Text(item.name)
.navigationTitle(item.name)
}
}
}
.listStyle(.sidebar)
Text("No selection")
}
}
}
A sample app with sidebar on the left showing 3 items and detail view on the right displaying the name of the selected item.
The final sample app with a sidebar.

Summary

We used NavigationView and NavigationLink to create a common layout for macOS apps, which features a sidebar with list of items and detailed view on the right. With only a bit of code, we were able to set it up.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Foundation iOS macOS Swift SwiftUI

Exploring AttributedString and custom attributes

WWDC’21 brought new APIs for creating attributed strings. AttributedString is a new value type which provides a type-safe API for dealing with attributes, its also localizable, supports limited amount of Markdown syntax, and can be even archived and unarchived thanks to the Codable support. In this blog post, we’ll take a look at the new API and will compose some attributed strings.

In addition to AttributedString type, there are many supporting types. For example, AttributeContainer is a collection of attributes which can be applied to the attributed string with one go. AttributeScope protocol groups attributes and AttributeScopes type contains several groups of attributes like AttributeScopes.swiftUI for attributed strings rendered in SwiftUI views. There is also AttributeScopes.uiKit, AttributeScopes.appKit, and AttributeScopes.foundation. Let’s now see how to create attributed strings and render them in a SwiftUI view.

An AttributedString with attributes applied to multiple ranges.

The attributed string visible above contains multiple attributes starting with background color attribute and finishing with a link attribute. In the snippet below we can find different ways how to set attributes: searching for a range, manually creating a range, using AttributedContainer for setting multiple attributes at once, and also setting attributes to the whole string. As this string is displayed in a SwiftUI view then all the used attributes are part of the SwiftUI attribute scope.

var string = AttributedString(localized: "The quick brown fox jumps over the lazy dog")
// Add attribute to the whole string
string.font = Font.system(size: 14, weight: .heavy, design: .rounded)
// Add a single attribute to a range
if let range = string.range(of: "The") {
string[range].backgroundColor = Color.indigo
}
if let range = string.range(of: "lazy") {
string[range].strikethroughColor = .yellow
string[range].strikethroughStyle = .single
}
// Add a link
if let range = string.range(of: "dog") {
string[range].link = URL(string: "https://www.apple.com")!
}
// Create a range manually with indices
let start = string.characters.index(string.startIndex, offsetBy: 10)
let end = string.characters.index(start, offsetBy: 5)
string[start..<end].foregroundColor = .brown
// Add multiple attributes at once to a range
if let range = string.range(of: "jumps") {
var attributeContainer = AttributeContainer()
attributeContainer.baselineOffset = 4
attributeContainer.kern = 5
attributeContainer.underlineColor = .cyan
attributeContainer.underlineStyle = .patternDot
string[range].mergeAttributes(attributeContainer)
}

If we would like to see the ranges attributes were set to, we can use the runs API. A single run is a set of attributes shared by a single range. If we print all the runs, in this case 9, then it would look like this:

The {
	NSLanguage = en
	NSPresentationIntent = [paragraph (id 1)]
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	SwiftUI.BackgroundColor = indigo
}
 quick  {
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	NSLanguage = en
	NSPresentationIntent = [paragraph (id 1)]
}
brown {
	NSPresentationIntent = [paragraph (id 1)]
	NSLanguage = en
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	SwiftUI.ForegroundColor = brown
}
 fox  {
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	NSLanguage = en
	NSPresentationIntent = [paragraph (id 1)]
}
jumps {
	SwiftUI.UnderlineColor = cyan
	NSLanguage = en
	SwiftUI.Kern = 5.0
	SwiftUI.BaselineOffset = 4.0
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	NSPresentationIntent = [paragraph (id 1)]
	NSUnderline = NSUnderlineStyle(rawValue: 256)
}
 over the  {
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	NSLanguage = en
	NSPresentationIntent = [paragraph (id 1)]
}
lazy {
	SwiftUI.StrikethroughColor = yellow
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	NSLanguage = en
	NSPresentationIntent = [paragraph (id 1)]
	NSStrikethrough = NSUnderlineStyle(rawValue: 1)
}
  {
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	NSLanguage = en
	NSPresentationIntent = [paragraph (id 1)]
}
dog {
	NSLink = https://www.apple.com
	SwiftUI.Font = Font(provider: SwiftUI.(unknown context at $7fff5bb7d400).FontBox<SwiftUI.Font.(unknown context at $7fff5bbfdb68).SystemProvider>)
	NSLanguage = en
	NSPresentationIntent = [paragraph (id 1)]
}

The new AttributedString API also supports custom attributes. Custom attributes need to conform to a AttributeStringKey protocol in bare minimum. But when we would like to benefit from using the custom attribute in Markdown and also allowing to decode and encode it to data with Codable then we would need to conform to MarkdownDecodableAttributedStringKey and CodableAttributedStringKey respectively. In a simple example, let’s create a new attribute named MessageAttribute which can store a value of Message struct with id and value fields. The MessageAttribute needs to define the type it stores and a name used when encoding and decoding. In addition, we’ll need to add a new attribute scope which contains the new attribute. As we intend to use the new attribute in a SwiftUI app then we’ll add swiftUI attributes to the scope as well.

struct Message: Hashable, Codable {
let id: String
let value: String
}
struct MessageAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
typealias Value = Message
static var name: String = "secretMessage"
}
extension AttributeScopes {
struct MyAppAttributes: AttributeScope {
let message: MessageAttribute
let swiftUI: SwiftUIAttributes
}
var myApp: MyAppAttributes.Type { MyAppAttributes.self }
}

With this set we can create attributed strings with this attribute either using markdown syntax or adding the attribute manually. Markdown syntax for custom attributes uses caret followed with square brackets and with the content in brackets after that. We also need to make sure to pass custom attribute scope into the AttributedString initializer as well. One thing what I have not figured out is how to create a completely custom appearance for custom attributes in SwiftUI views, like we can do in UIKit views.

var string = AttributedString(localized: "This contains my message attribute", including: \.myApp)
let start = string.startIndex
let end = string.characters.index(start, offsetBy: 4)
string[start..<end].myApp.message = Message(id: "message_id", value: "secret")
// print(string.runs):
// This {
// NSPresentationIntent = [paragraph (id 1)]
// NSLanguage = en
// secretMessage = Message(id: "message_id", value: "secret")
// }
// contains my message attribute {
// NSPresentationIntent = [paragraph (id 1)]
// NSLanguage = en
// }
let string = AttributedString(localized: "This contains my ^[message](secretMessage: {id: 'message_id', value: 'Hello!'}) attribute in markdown", including: \.myApp)
// print(string.runs)
// This contains my {
// NSPresentationIntent = [paragraph (id 1)]
// NSLanguage = en
// }
// message {
// NSLanguage = en
// NSPresentationIntent = [paragraph (id 1)]
// secretMessage = Message(id: "message_id", value: "Hello!")
// }
// attribute in markdown {
// NSLanguage = en
// NSPresentationIntent = [paragraph (id 1)]
// }

Summary

We took a look on AttributedString, AttributeContainer, AttributeScope, and created attributed strings with the new API. With this knowledge, we got going with the new API and can continue exploring it further. The last thing to mention is that AttributedString can be converted to and from NSAttributedString with breeze.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Swift SwiftUI

Subtitled button in SwiftUI

Buttons in SwiftUI are represented with a Button type. Buttons are easy to create and can be customized either with directly providing a styled label or using a ButtonStyle protocol to define the visual appearance. Quite often we can find ourselves in a situation where we need a button which has a title and also a subtitle. In this blog post I am gonna show how to create a SubtitledButton which does exactly that. We’ll also use ButtonStyle protocol to change the appearance of the button. Using buttonStyle view modifier it is easy to change the appearance of the SubtitledButton as sometimes we want to present it in different ways. In the example below we can see two SubtitledButtons where one has a plain and second a custom appearance.

SwiftUI view with two buttons what have title and subtitle. The first button has no background and the second one has blue background with rounded corners.
A SwiftUI view with two SubtitledButtons with different styles.
struct ContentView: View {
var body: some View {
VStack(spacing: 32) {
Text("Hello, world!")
SubtitledButton(title: "First Title", subtitle: "First Subtitle", action: tapAction)
.buttonStyle(PlainButtonStyle())
SubtitledButton(title: "Second Title", subtitle: "Second Subtitle", action: tapAction)
.buttonStyle(RoundedRowButtonStyle())
}.padding()
}
func tapAction() {
print("tapped")
}
}

Let’s now take a look at the SubtitledButton. It is a SwiftUI view which just incorporates a SwiftUI button with two Text values. The first Text represents the title and the second Text represents the subtitle. The title font can be changed by applying a font view modifier on the SubtitledButton. In the example below, the subtitle font is currently not configurable, although we could add another argument if needed. As the subtitle already has font view modifier applied then setting a font modifier on the whole button it does not override it.

struct SubtitledButton: View {
let title: LocalizedStringKey
let subtitle: LocalizedStringKey
let action: () -> Void
var body: some View {
Button(action: action, label: {
VStack(spacing: 4) {
Text(title)
Text(subtitle).font(.footnote)
}
})
}
}

ButtonStyle is a protocol in SwiftUI which is used for custom button appearances. In the makeBody function we can apply view modifiers to the label where the label is a view created when the Button was created.

struct RoundedRowButtonStyle: ButtonStyle {
@ViewBuilder func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity)
.padding(8)
.background(Color.accentColor)
.cornerRadius(8)
.foregroundColor(.white)
}
}

Example Project

SwiftUISubtitledButton (Xcode 12.5)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.