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. When you compose them in this way you only need to retain the "bottom most" wiretap. Examples follow.

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.
@wiretap = MW(@label, :text).bind_to(MW(@text_view, :text))

# assign the attributedText of the label to the text view, doing some
# highlighting in-between.
@wiretap = @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.
@wiretap = 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
  )

See how in the example above I only retain the "final" signal (the return value from #combine is what gets retained by @wiretap). That's what I mean by the "bottom most" wiretap. Don't worry, the intermediate wiretaps get retained.

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`, which means they'll be sent together
# when either one changes
person = Person.new
@info = nil
@taps = Motion.wiretap([
  Motion.wiretap(person, :name),
  Motion.wiretap(person, :email),
]).combine do |name, email|
  @info = "#{name} <#{email}>"
end

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

# With reduce you get a memo and a value, and you should return a new value. Be
# careful about mutable structures: the initial memo is retained, not copied.
person_1 = Person.new
person_2 = Person.new
@names = []
@taps = Motion.wiretap([
  Motion.wiretap(person_1, :name),
  Motion.wiretap(person_2, :name),
]).reduce([]) do |memo, name|
  memo + [name]
end.listen do |names|
  @names = names.inspect
end
person_1.name = 'Mr. White'
# @names => ["Mr. White", nil]
person_2.name = 'Mr. Blue'
# @names => ["Mr. White", "Mr. Blue"]

Monitoring jobs

There is a "short form" and "long form" to these wiretaps. The "short form" is something like Motion.wiretap(proc) do (block) end. The proc will be executed, and when it's done, the block will execute.

The "long form" is different only in that the block is passed to the and_then method, not the initializer: Motion.wiretap(proc).and_then do ... end. In this form, you must call start on the wiretap:

# Monitor for background job completion, short form:
@wiretap = Motion.wiretap(-> do
  this_will_take_forever!
end) do
  puts "done!"
end

# Monitor for background job completion, long form:
@wiretap = Motion.wiretap(-> do
  this_will_take_forever!
end).and_then do
  puts "done!"
end.start  # you must call 'start' explicitly

# Same again, 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).
@wiretap = Motion.wiretap(-> do
  this_will_take_forever!
end).queue(Dispatch::Queue.concurrent).and_then do
  puts "done!"
end
@wiretap.start

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

Gestures

Possible gestures:

:tap, :pinch, :rotate, :swipe, :pan, :press

Options:

:taps (:tap, :press)
:fingers (:tap, :swipe, :pan, :press)
:direction (:direction)
:min_fingers (:pan)
:max_fingers (:pan)
:duration (:press)
@wiretap = Motion.wiretap(view).on(:tap) do |gesture|
  point = gesture.locationInView(view)
  puts "you tapped #{point.x}, #{point.y}"
end

Control events

Possible events:

:touch, :touch_up, :touch_down, :touch_start, :touch_stop, :change, :begin,
:end, :touch_down_repeat, :touch_drag_inside, :touch_drag_outside,
:touch_drag_enter, :touch_drag_exit, :touch_up_inside, :touch_up_outside,
:touch_cancel, :value_changed, :editing_did_begin, :editing_changed,
:editing_did_change, :editing_did_end, :editing_did_end_on_exit
:all_touch, :all_editing, :application, :system, :all
@wiretap = Motion.wiretap(control).on(:touch) do |event|
end

Notifications

@wiretap = Motion.wiretap('NotificationName') do |object, |
  puts 'notification received!'
end

NSNotificationCenter.defaultCenter.postNotificationName('NotificationName', object: nil, userInfo: info)