MotionWiretap

An iOS / OS X library heavily inspired by ReactiveCocoa.

gem install motion-wiretap

Run the iOS specs using rake spec Run the OSX specs using rake spec platform=osx

First things first

You MUST retain any wiretaps you create, otherwise they will be garbage collected immediately. I don't yet have a clever way to avoid this unfortunate state of affairs, other than keeping some global list of wiretaps. UG to that.

How does ReactiveCocoa do it, anyone know? I'd love to mimic that.

This isn't a terrible thing, though, since most wiretaps require that you call the cancel! method. Again, I'm not sure how ReactiveCocoa accomplishes the "auto shutdown" of signals that rely on notifications and key-value observation.

Showdown

Open these side by side to see the comparison:

reactive.rb reactive.mm

Usage

Creating a wiretap is done using the factory method Motion.wiretap(), also aliased as MW().

@wiretap = Motion.wiretap(obj, :property)  # this object will notify listeners everytime obj.property changes
@wiretap = MW(obj, :property)

If you want to use a more literate style, you can include the motion-wiretap-polluting to have a wiretap method added to your objects. I like this style, but the default is to have a non-polluting system.

@wiretap = obj.wiretap(:property)

Wiretaps can be composed all sorts of ways: you can filter them, map them, combine multiple wiretaps into a single value, use them to respond to notifications or control events.

Let's start with something practical!

In these examples I will use all three ways of creating a Wiretap (MW(), Motion.wiretap(), object.wiretap).

# assign the label to the text view; changes to the text view will be reflected
# on the label.
MW(@label, :text).bind_to(MW(@text_view, :text))

# assign the attributedText of the label to the text view, doing some
# highlighting in-between.
@label.wiretap(:attributedText).bind_to(@text_view.wiretap(:text).map do |text|
  NSAttributedString.alloc.initWithString(text, attributes: { NSForegroundColorAttributeName => UIColor.blueColor })
end)

# This code will set the 'enabled' property depending on whether the username
# and password are not empty.
Motion.wiretap(@login_button, :enabled).bind_to(
  Motion.wiretap([
    Motion.wiretap(@username_field, :text),
    Motion.wiretap(@password_field, :text),
  ]).combine do |username, password|
    # use motion-support to get the 'present?' method
    username.present? && password.present?
  end
  )

Types of wiretaps

  • Key-Value Observation / KVO
  • Arrays (map/reduce/combine)
  • Jobs (event stream, completion)
  • UIView Gestures
  • UIControl events
  • NSNotificationCenter

Key-Value Observation

class Person
  attr_accessor :name
  attr_accessor :email
end

# listen for changes
person = Person.new
# you need to store the wiretap object in memory; when the object is
# deallocated, it will no longer receive updates.
wiretap = Motion.wiretap(person, :name)
# react to change events
wiretap.listen do |name|
  puts "name is now #{name}"
end
person.name = 'Joe'
# puts => "name is now Joe"

# Since listening is very common, you can easily shorthand this to:
wiretap = Motion.wiretap(person, :name) do |name|
  puts "name is now #{name}"
end

# bind the property of one object to the value of another
person_1 = Person.new
person_2 = Person.new
wiretap = Motion.wiretap(person_1, :name)
wiretap.bind_to(person_2, :name)  # creates a new Wiretap object for person_2; changes to person_2.name will affect person_1
person_2.name = 'Bob'
person_1.name # => "Bob"

# cancel a wiretap
wiretap.cancel!
person_2.name = 'Jane'
person_1.name
# => "BOB"

# bind the property of one object to the value of another, but change it using `map`
wiretap = Motion.wiretap(person_1, :name).bind_to(Motion.wiretap(person_2, :name).map { |value| value.upcase })
person_2.name = 'Bob'
person_1.name # => "BOB"

Working with arrays

# combine the values `name` and `email`
person = Person.new
taps = Motion.wiretap([
  Motion.wiretap(person, :name),
  Motion.wiretap(person, :email),
]).combine do |name, email|
  puts "#{name} <#{email}>"
end

person.name = 'Kazuo'
# puts => "Kazuo <>"
person.email = '[email protected]'
# puts => "Kazuo <[email protected]>"

# reduce/inject
person_1 = Person.new
person_2 = Person.new
taps = Motion.wiretap([
  Motion.wiretap(person_1, :name),
  Motion.wiretap(person_2, :name),
]).reduce do |memo, name|
  memo ||= []
  memo + [name]
end
wiretap.listen do |names|
  puts names.inspect
end
person_1.name = 'Mr. White'
person_1.name = 'Mr. Blue'
# => ["Mr. White", "Mr. Blue"]

# you can provide an initial 'memo' (default: nil)
Motion.wiretap([
  Motion.wiretap(person_1, :name),
  Motion.wiretap(person_2, :name),
]).reduce([]) do |memo, name|
  memo + [name]  # you should not change memo in place, the same one will be used on every change event
end

Monitoring jobs

# Monitor for background job completion:
Motion.wiretap(-> do
  this_will_take_forever!
end).and_then do
  puts "done!"
end.start

# Same, but specifying the thread to run on. The completion block will be called
# on this thread, too. The queue conveniently accepts a completion handler
# (delegates to the `Wiretap#and_then` method).
Motion.wiretap(-> do
  this_will_take_forever!
end).queue(Dispatch::Queue.concurrent).and_then do
  puts "done!"
end.start

# Send a stream of values from a block. A lambda is passed in that will forward
# change events to the `listen` block
Motion.wiretap(-> (on_change) do
  5.times do |count|
    on_change.call count
  end
end).listen do |index|
  puts "...#{index}"
end.and_then do
  puts "done!"
end.start
# => puts "...0", "...1", "...2", "...3", "...4", "done!"

Notifications

notification = Motion.wiretap('NotificationName') do
  puts 'notification received!'
end
# the notification observer will be removed when the Wiretap is dealloc'd

Gestures