00:06 In the previous
episode
of this series, we built a reactive list. Today we'll use this component to
build a reactive, observable array that we can eventually feed to a table view.
We're going to need operations like filtering and sorting, and changes to the
original array should be funneled to the filtered and sorted array as well.
RArray
01:05 We remove the sample code from last time and start writing our
reactive array, which is based on an initial value and a list of changes. The
signature of the change list looks impressive with all those generic brackets,
but simply said, it's a property — so that it can be observed — holding a list
of array changes:
struct RArray<A> {
let initial: [A]
let changes: Property<RList<ArrayChange<A>>>
}
02:09 An array change is either an insertion of a value at a given index
or a removal of the element at a given index:
enum ArrayChange<A> {
case insert(A, at: Int)
case remove(at: Int)
}
Reduce to Latest Value
02:41 In the reactive array, we want to calculate the current value by
applying the array changes to the initial array. In an extension on Array
, we
write the apply method. With this mutating method, we can also provide a
non-mutating version, which copies the array:
extension Array {
mutating func apply(_ change: ArrayChange<Element>) {
switch change {
case let .insert(value, idx):
insert(value, at: idx)
case let .remove(idx):
remove(at: idx)
}
}
func applying(_ change: ArrayChange<Element>) -> [Element] {
var copy = self
copy.apply(change)
return copy
}
}
05:11 Now we calculate the latest value of an RArray
by applying all
changes to its initial value. We should be able to observe this latest value, so
we wrap it in a Property
. To apply the changes, we call the reduce
method we
wrote in the previous episode:
struct RArray<A> {
let initial: [A]
let changes: Property<RList<ArrayChange<A>>>
var latest: Property<[A]> {
return changes.flatMap(.latest) { changeList in
changeList.reduce(self.initial) { $0.applying($1) }
}
}
}
07:04 Both the reduce
method and changes
return a Property
, which
is why we have to call flatMap
. Otherwise, we'd end up with a
Property<Property<...>>
.
Mutating and Observing RArray
07:26 We can try out RArray
with an empty list of changes and an
initial array of integers:
let changes = MutableProperty<RList<ArrayChange<Int>>>(RList(array: []))
let arr = RArray(initial: [1,2,3], changes: Property(changes))
08:49 We observe the latest version of the array and add a change, which
prints out the updated value:
arr.latest.signal.observeValues { print($0) }
append(ArrayChange.remove(at: 0), to: changes)
09:41 At this point, we can observe the latest value of an array, append
changes, and get notified with the value that results from applying all changes
to the initial array. That's a lot of overhead to create a mutable array, but
the cool thing is that we can now build reactive methods on top of it so that we
can filter and sort the array and observe the latest result of these operations.
Before we add filtering, we can do some refactoring.
10:36 We move the sample code into a convenience method that constructs
a mutable RArray
from an initial value alone and returns it along with a
function to append changes:
struct RArray<A> {
static func mutable(_ initial: [A]) -> (RArray<A>, appendChange: (ArrayChange<A>) -> ()) {
let changes = MutableProperty<RList<ArrayChange<A>>>(RList(array: []))
let result = RArray(initial: initial, changes: Property(changes))
return (result, { change in append(change, to: changes)})
}
}
12:14 This cleans up our code when we create a mutable array. And this
way, the fact that we use RList
to keep track of changes is hidden inside our
implementation:
let (arr, addChange) = RArray.mutable([1,2,3])
arr.latest.signal.observeValues { print($0) }
addChange(.remove(at: 0))
Filter
13:04 Next up is the filter method, which takes a condition,
isIncluded
, and returns a new RArray
:
struct RArray<A> {
func filter(_ isIncluded: (A) -> Bool) -> RArray<A> {
}
}
13:49 The purpose of the filter method is to create a filtered RArray
.
If the original array changes in a way that affects the filtered array, then
these changes should be applied there as well, and we want to get notified.
14:09 We take the initial value, filter it, and return a mutable
RArray
without exposing the addChange
function:
func filter(_ isIncluded: (A) -> Bool) -> RArray<A> {
let filtered = initial.filter(isIncluded)
let (result, addChange) = RArray.mutable(filtered)
return result
}
15:33 We're not observing changes yet, but the filter should already
work. We test the filter by including only the even values:
let filtered = arr.filter { $0 % 2 == 0 }
filtered.latest.value
16:19 Now we want to change arr
and see this change reflected in the
filtered array. In the filter method, we have to observe the original array's
changes
property. When the list of changes is updated, we receive the latest
changes as an RList
that can be used to reduce:
func filter(_ isIncluded: (A) -> Bool) -> RArray<A> {
let filtered = initial.filter(isIncluded)
let (result, addChange) = RArray.mutable(filtered)
changes.signal.observeValues { latestChanges in
}
return result
}
17:19 The changes use indices that refer to the initial array, so we
have to start reduce
with the initial array value as well and work our way
through the changes. In the combine function, we get the intermediate version of
the array and the current change to apply. In addition to applying the change,
we switch over it to see how we process both cases for the filtered array:
latestChanges.reduce(self.initial) { intermediate, change in
switch(change) {
case let .insert(value, idx): case let .remove(idx): }
return intermediate.applying(change)
}
19:43 An insertion into the original array can be ignored if the value
shouldn't be included in the filtered array. Likewise, a removal from the
original array can be ignored if the value wasn't included in the filtered
array. We can add these conditions to our switch statement. We also have to add
a default case in which we don't do anything:
latestChanges.reduce(self.initial) { intermediate, change in
switch(change) {
case let .insert(value, idx) where isIncluded(value): case let .remove(idx) where isIncluded(intermediate[idx]): default: break
}
return intermediate.applying(change)
}
21:31 In order to include a value in the filtered array, we have to
calculate the index to determine where to insert the value. This is based on how
many elements were filtered out up to that index:
extension Array {
func filteredIndex(for index: Int, _ isIncluded: (Element) -> Bool) -> Int {
var skipped = 0
for i in 0..<index {
if !isIncluded(self[i]) {
skipped += 1
}
}
return index - skipped
}
}
23:20 Because we're passing the isIncluded
function on to the index
calculation, we have to mark it as escaping in the method signature. Now we can
apply changes to the filtered array:
func filter(_ isIncluded: @escaping (A) -> Bool) -> RArray<A> {
let filtered = initial.filter(isIncluded)
let (result, addChange) = RArray.mutable(filtered)
changes.signal.observeValues { latestChanges in
latestChanges.reduce(self.initial) { intermediate, change in
switch change {
case let .insert(value, idx) where isIncluded(value):
let newIndex = intermediate.filteredIndex(for: idx, isIncluded)
addChange(.insert(value, at: newIndex))
case let .remove(idx) where isIncluded(intermediate[idx]):
let newIndex = intermediate.filteredIndex(for: idx, isIncluded)
addChange(.remove(at: newIndex))
default: break
}
return intermediate.applying(change)
}
}
return result
}
Fixing a Bug
24:47 The code compiles, so we try it out. We append 4 to the reactive
array and verify that it was inserted:
let (arr, addChange) = RArray.mutable([1,2,3])
arr.latest.signal.observeValues { print($0) }
addChange(.insert(4, at: 3))
arr.latest.value
let filtered = arr.filter { $0 % 2 == 0 }
filtered.latest.value
25:18 The value 4 got inserted, but the filtered array has the wrong
value. It should contain 2 and 4, but it still only contains 2. Apparently, the
change doesn't get included in the RArray
we got from the filter
method.
25:59 If we create the filtered array before changing the original
array, we get the expected result:
let (arr, addChange) = RArray.mutable([1,2,3])
arr.latest.signal.observeValues { print($0) }
let filtered = arr.filter { $0 % 2 == 0 }
addChange(.insert(4, at: 3))
arr.latest.value
filtered.latest.value
26:18 It turns out that the filtered array starts observing the changes
from the moment we create it, but it doesn't take into account the initial list
of changes. We fix this bug by performing the filter's reduce
operation
straight away with the current value of the changes list:
func filter(_ isIncluded: @escaping (A) -> Bool) -> RArray<A> {
let filtered = initial.filter(isIncluded)
let (result, addChange) = RArray.mutable(filtered)
func filterH(_ latestChanges: RList<ArrayChange<A>>) {
latestChanges.reduce(self.initial) { intermediate, change in
switch change {
case let .insert(value, idx) where isIncluded(value):
let newIndex = intermediate.filteredIndex(for: idx, isIncluded)
addChange(.insert(value, at: newIndex))
case let .remove(idx) where isIncluded(intermediate[idx]):
let newIndex = intermediate.filteredIndex(for: idx, isIncluded)
addChange(.remove(at: newIndex))
default: break
}
return intermediate.applying(change)
}
}
changes.signal.observeValues(filterH)
filterH(changes.value)
return result
}
27:52 We print out the latest filtered value and add some changes to
the original array. Now we get a print when we insert a value that also belongs
in the filtered array:
let (arr, addChange) = RArray.mutable([1,2,3])
addChange(.insert(4, at: 3))
let filtered = arr.filter { $0 % 2 == 0 }
filtered.latest.signal.observeValues { print($0) }
addChange(.insert(5, at: 4)
addChange(.insert(6, at: 5)
To Be Continued
28:58 It's not easy to write these reactive algorithms. On the plus
side, we don't have to keep writing them — once we have filter
and sort
for
a reactive array and we've tested them well, we can keep using and combining
them. We could also port these operations to a ReactiveSequence
or
ReactiveCollection
protocol and make them work for all kinds of reactive
structures at once.
29:47 We're almost ready to let our reactive components drive a table
view; we only need a way to sort elements in a custom order. Once we add
sorting, we can compose the reduce, filter, and sort operations and observe
changes of the result. In the observer, we'll have access to the correct
indices, which we can directly apply to a table view. Let's do all that next
time!