This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We build a reactive array type on top of the reactive list from episode #67 and implement a filter method.

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)
// prints: [2, 3]

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 // [2]

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 // [1, 2, 3, 4]

let filtered = arr.filter { $0 % 2 == 0 }
filtered.latest.value // [2]

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 // [1, 2, 3, 4]

filtered.latest.value // [2]

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)
// prints: [2, 4, 6]

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!

Resources

  • Playground

    Written in Swift 4

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

40 Episodes · 15h47min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes