00:06 Today we'll start building a token view. We can find such an input
view in the Mac Mail app, which lets us type in a search term, and it turns the
term into a blue bubble. The original Mac version is implemented using
NSTextView
, which takes care of a lot of the functionality, but we want to see
how far we can get in pure SwiftUI.
00:56 Currently, one of the problems with the state of SwiftUI is that we
can't really use an existing component, like NSTextView
, and customize it for
our use case. So, because we can't hook into a text field, we have to basically
start from scratch.
Token Views
01:22 Our TokenField
view will be generic over a Token
type, which is
Identifiable
, and over a TokenView
:
struct TokenField<Token: Identifiable, TokenView: View>: View {
}
01:43 We add properties to accept an array of tokens and a view builder.
We call the view builder to generate a view for each token, placing those views
in an HStack
:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
var body: some View {
HStack {
ForEach(tokens) { token in
tokenView(token)
}
}
}
}
02:22 We write a sample MyToken
struct, and we create some sample
values to work with:
struct MyToken: Identifiable {
var id = UUID()
var text: String
}
let sample = [
"mail@objc.io",
"mail@apple.com",
"test@test.example"
].map { MyToken(text: $0) }
02:52 In our ContentView
, we add a TokenField
, and we pass in the
sample tokens:
struct ContentView: View {
var body: some View {
TokenField(tokens: sample) { token in
}
.padding()
}
}
03:13 In the view builder closure of the TokenView
, we output each
token's text in a Text
view with some small padding and a light blue
background with rounded corners:
struct ContentView: View {
var body: some View {
TokenField(tokens: sample) { token in
Text(token.text)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.2), in: .rect(cornerRadius: 4))
}
.padding()
}
}
TODO screenshot at 04:35
Insertion Point
04:37 Next, we want to be able to navigate the token field with a
cursor, or an insertion point. Since we're not in an actual text field, we'll
fake the cursor by creating our own view for it. The position of this cursor
will be defined by a state property, selection
. For now, we'll use an integer
to represent this position, where 0
means the cursor is in front of the first
token view:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
}
06:04 We create an InsertionPoint
view consisting of a rectangle
that's two points wide, and we add it in an overlay over the HStack
of tokens:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
var body: some View {
HStack {
ForEach(tokens) { token in
tokenView(token)
}
}
.overlay {
InsertionPoint()
}
}
}
struct InsertionPoint: View {
var body: some View {
Color.blue
.frame(width: 2)
}
}
06:47 The insertion point shows up in the middle, but we want to align
it to the selection. Let's do this by applying a matched geometry effect between
the tokens and the insertion point. We add the effect to each token view using
the token's ID as the identifier for the effect:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var body: some View {
HStack {
ForEach(tokens) { token in
tokenView(token)
.matchedGeometryEffect(id: token.id, in: namespace)
}
}
.overlay {
InsertionPoint()
}
}
}
07:31 One of these instances of the matched geometry effect will be used
as the source for the insertion point's geometry, depending on the selected
token. We add a computed property that returns the ID of the token at the
position marked by selection
, so we can use it for the insertion point's
matched geometry effect. We also define this effect to not be the source, so
that it will take on the selected token view's geometry:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID {
return tokens[selection].id
}
var spacing: CGFloat = 6
var body: some View {
HStack {
ForEach(tokens) { token in
tokenView(token)
.matchedGeometryEffect(id: token.id, in: namespace)
}
}
.padding(.trailing, spacing)
.matchedGeometryEffect(id: "endPosition", in: namespace)
.overlay {
InsertionPoint()
.matchedGeometryEffect(id: selectedID, in: namespace, isSource: false)
}
}
}
08:57 This moves the insertion point, but it's still not at the leading
edge of the first token view. We can try specifying a leading anchor point for
the matched geometry effect:
InsertionPoint()
.matchedGeometryEffect(id: selectedID, in: namespace, anchor: .leading, isSource: false)
09:29 Still not quite right. We instead add a flexible frame around the
two-points-wide insertion point, and we give the frame a .leading
alignment:
InsertionPoint()
.frame(maxWidth: .infinity, alignment: .leading)
.matchedGeometryEffect(id: selectedID, in: namespace, isSource: false)
09:57 Now it sits at the beginning of the token view. If we look closely
at what Mail does, we see that the insertion point should be centered to the
leading edge of the token view. We achieve this by giving it an offset of one
point:
InsertionPoint()
.offset(x: -1)
.frame(maxWidth: .infinity, alignment: .leading)
.matchedGeometryEffect(id: selectedID, in: namespace, isSource: false)
10:42 To make the rectangle look more like an insertion point, we want
to make it blink. In onAppear
, we set a didAppear
state property to true
,
and we use this property to change the opacity of the rectangle. By wrapping the
mutation of didAppear
in a withAnimation
block, and by using an repeating
animation, the cursor starts to blink:
struct InsertionPoint: View {
@State private var didAppear = false
var body: some View {
Color.blue
.frame(width: 2)
.opacity(didAppear ? 0 : 1)
.onAppear {
withAnimation(.linear(duration: 8/60).delay(0.4).repeatForever()) {
didAppear = true
}
}
}
}
Moving the Selection
12:01 Now we need a way to move the cursor between the token views.
Using the onKeyPress
modifier, we can define an action to be called when the
user presses a key on their keyboard. If the pressed key is the right arrow key,
we increment the selection
integer by one — and we'll check that we don't go
out of bounds later on:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID {
return tokens[selection].id
}
var body: some View {
HStack {
}
.overlay {
}
.onKeyPress { press in
switch press.key {
case .rightArrow:
selection += 1
return .handled
default:
return .ignored
}
}
}
}
12:40 This isn't working yet, because we need to make our view focusable
first. We also disable the focus effect so that we don't see a blue ring around
the TokenField
view:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID {
return tokens[selection].id
}
var body: some View {
HStack {
}
.overlay {
}
.focusable()
.focusEffectDisabled()
.onKeyPress { press in
switch press.key {
case .rightArrow:
selection += 1
return .handled
default:
return .ignored
}
}
}
}
Checking Bounds
13:03 To prevent crashing when we press the arrow key too many times, we
check if the selection is smaller than the number of tokens. This still allows a
crash if the button is pressed when the cursor is at the last token, but we'll
want to handle this as a special case:
.onKeyPress { press in
switch press.key {
case .rightArrow:
if selection < tokens.count {
selection += 1
}
return .handled
default:
return .ignored
}
}
13:41 We want to allow the insertion point to be placed after the last
token, which means we have to make the selectedID
optional. If it's nil
, we
know that we have to draw the insertion point at the end of the token field,
ready for new input:
var selectedID: Token.ID? {
guard selection < tokens.count else { return nil }
return tokens[selection].id
}
14:38 When selectedID
returns nil
, we could use a matched geometry
effect on an extra view at the end of the HStack
:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID? {
guard selection < tokens.count else { return nil }
return tokens[selection].id
}
var body: some View {
HStack {
ForEach(tokens) { token in
tokenView(token)
.matchedGeometryEffect(id: AnyHashable(token.id), in: namespace)
}
Color.clear
.frame(width: 0)
.matchedGeometryEffect(id: "endPosition", in: namespace)
}
.overlay {
}
.focusable()
.focusEffectDisabled()
.onKeyPress { press in
}
}
}
16:12 In the overlay, we adjust the insertion point's matched geometry
effect to use the "endPosition"
ID in case selectID
is nil
. But to make
this possible, we have to wrap both IDs in AnyHashable
, because the
nil-coalescing operator requires the same type of value to be returned from both
sides of the operator:
.overlay {
InsertionPoint()
.offset(x: -1)
.frame(maxWidth: .infinity, alignment: selectedID == nil ? .trailing : .leading)
.matchedGeometryEffect(id: selectedID.map { AnyHashable($0) } ?? AnyHashable("endPosition") , in: namespace, isSource: false)
}
17:38 Our cursor is now as tall as the window, because it adopts the
geometry of the Color.clear
shape, which accepts all the available space
proposed to it. So perhaps it's not a good idea to use this view as a source for
the geometry. Instead, we can place the matched geometry effect on the HStack
,
so that the cursor becomes only as tall as the maximum height of the token
views:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID? {
guard selection < tokens.count else { return nil }
return tokens[selection].id
}
var body: some View {
HStack {
ForEach(tokens) { token in
tokenView(token)
.matchedGeometryEffect(id: AnyHashable(token.id), in: namespace)
}
}
.matchedGeometryEffect(id: "endPosition", in: namespace)
.overlay {
}
.focusable()
.focusEffectDisabled()
.onKeyPress { press in
}
}
}
18:31 Then we change the alignment of the flexible frame around the
insertion point to .trailing
if selectID
is nil
, which places the cursor
at the end of the stack view:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID? {
guard selection < tokens.count else { return nil }
return tokens[selection].id
}
var body: some View {
HStack {
ForEach(tokens) { token in
tokenView(token)
.matchedGeometryEffect(id: AnyHashable(token.id), in: namespace)
}
}
.padding(.trailing, spacing)
.matchedGeometryEffect(id: "endPosition", in: namespace)
.overlay {
InsertionPoint()
.offset(x: -1)
.frame(maxWidth: .infinity, alignment: selectedID == nil ? .trailing : .leading)
.matchedGeometryEffect(id: selectedID.map { AnyHashable($0) } ?? AnyHashable("endPosition") , in: namespace, isSource: false)
}
.focusable()
.focusEffectDisabled()
.onKeyPress { press in
}
}
}
18:47 That works, but something's off when we try to move the cursor to
one of the token views. Perhaps we can display some debug information to see
what's going on. Using the safeAreaInset
modifier, we can easily attach a text
view to the bottom of the TokenField
. We can use this text view to display the
current value of selection
:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID? {
guard selection < tokens.count else { return nil }
return tokens[selection].id
}
var body: some View {
HStack {
}
.safeAreaInset(edge: .bottom) {
debugInfo
}
}
var debugInfo: some View {
Text("\(selection)")
}
}
20:25 The selection changes correctly when we press the right arrow
key, so something must be off with our matched geometry effects. And indeed, we
forgot to also wrap the IDs of the token views in AnyHashable
, so there was a
mismatch between the effect ID of the insertion point and the IDs of the source
effects:
HStack {
ForEach(tokens) { token in
tokenView(token)
.matchedGeometryEffect(id: AnyHashable(token.id), in: namespace)
}
}
}
20:46 That fixes the navigation, so now we only have to add some extra
padding at the end of the stack view to create some space between the last token
view and the cursor:
struct TokenField<Token: Identifiable, TokenView: View>: View {
var tokens: [Token]
@ViewBuilder var tokenView: (Token) -> TokenView
@State private var selection = 0
@Namespace private var namespace
var selectedID: Token.ID? {
guard selection < tokens.count else { return nil }
return tokens[selection].id
}
var spacing: CGFloat = 6
var body: some View {
HStack(spacing: spacing) {
ForEach(tokens) { token in
tokenView(token)
.matchedGeometryEffect(id: AnyHashable(token.id), in: namespace)
}
}
.padding(.trailing, spacing)
.matchedGeometryEffect(id: "endPosition", in: namespace)
.overlay {
}
}
}
TODO screenshot with cursor visible at end, at 21:20
More Keypresses
21:21 Let's also listen for presses of the left arrow key to move the
cursor back:
.onKeyPress { press in
switch press.key {
case .leftArrow:
if selection > 0 {
selection -= 1
}
return .handled
case .rightArrow:
if selection < tokens.count {
selection += 1
}
return .handled
default:
return .ignored
}
}
21:38 We need to return .handled
or .ignored
from the modifier's
closure to tell the system if the key press event should bubble up further. By
returning .handled
, we consume the event, and we prevent other actions from
being triggered by the same key press.
21:51 Holding down the command key should make the cursor jump to the
beginning or the end of the token field. This can be easily supported by
checking the modifiers
property of the key press:
.onKeyPress { press in
switch press.key {
case .leftArrow:
if press.modifiers.contains(.command) {
selection = 0
} else if selection > 0 {
selection -= 1
}
return .handled
case .rightArrow:
if press.modifiers.contains(.command) {
selection = tokens.count
} else if selection < tokens.count {
selection += 1
}
return .handled
default:
return .ignored
}
}
22:32 We now have basic navigation in place. A next step could be to
abstract away the selection logic and add a range to the selection so that we
can highlight the tokens. By pulling the logic out, we'll make it possible to
test the token field's behavior in response to key presses.