The RubyMotion layout and styling gem.
- Crossplatform compatibility: iOS, OSX, and planned support for Android
- Simple, easy to learn DSL
- Crossframework compatibility:
- Easily extendable to support custom, mini-DSLs
- Non-polluting
- ProMotion/RMQ/SugarCube-compatible (kind of goes hand-in-hand with being non-polluting)
- Styles and layouts are "just code" (not hash-based like in Teacup)
- Written by the authors of ProMotion and Teacup
You can read all about why Colin decided that Teacup needed to be replaced with a new project, rather than upgraded or refactored.
If you need to update your app to use MotionKit, see READMORE.md for an example of migrating stylesheets, styles, and constraints.
In your Gemfile
gem'motion-kit'From your controller you will instantiate a MotionKit::Layout instance, and
request views from it. layout.view is the root view, and it's common to
assign this to self.view in your loadView method. You'll also want to hook
up your instance variables, using layout.get(:id) or using instance variables.
classLoginController < UIViewControllerdefloadView@layout=LoginLayout.newself.view=@layout.view@button=@layout.get(:button)# This will be created in our layout (below)@button=@layout.button# Alternatively you can use instance variables and accessor methodsenddefviewDidLoad@button.on(:touch){my_code}# Mix with some SugarCube for sweetness!rmq(@button).on(:touch){my_code}# and of course RMQ works just as wellendendIn a layout class, the layout method is expected to create the view hierarchy,
and it should also take care of frames and styling. You can apply styles here,
and it's handy to do so when you are creating a quick mock-up, or a very small
app. But in a real application, you'll want to include a Stylesheet module so
your layout isn't cluttered with all your styling code.
Here's a layout that just puts a label and a button in the middle of the screen:
classSimpleLayout < MotionKit::Layout# this is a special attr method that calls `layout` if the view hasn't been# created yet. So you can call `layout.button` before `layout.view` and you# won't get nil, and layout.view will be built.view:buttondeflayoutaddUILabel,:label@button=addUIButton,:buttonenddeflabel_styletext'Hi there! Welcome to MotionKit'fontUIFont.fontWithName('Comic Sans',size: 24)size_to_fit# note: there are better ways to set the center, see the frame helpers belowcenter[CGRectGetMidX(superview.bounds),CGRectGetMidY(superview.bounds)]text_alignmentNSTextAlignmentCentertext_colorUIColor.whiteColor# if you prefer to use shorthands from another gem, you certainly can!background_colorrmq.color.white# from RMQbackground_color:white.uicolor# from SugarCubeenddefbutton_style# this will call 'setTitle(forState:)' via a UIButton helpertitle'Press it!'size_to_fit# this shorthand is much better! More about frame helpers below.center['50%','50% + 50']endendThat's easy enough, right? In this next, more complicated layout, we'll
create a login page with a 'Login' button and inputs for username and password.
I will assign the frame in the layout method instead of in the _style methods.
This is purely an aesthetic choice. Some people like to have their frame code in
the layout method, others like to put it in the *_style methods.
classLoginLayout < MotionKit::Layout# we write our `_style` methods in a moduleincludeLoginStylesdeflayout# we know it's easy to add a subview, with a stylename...addUIImageView,:logo# inside a block you can set properties on that viewaddUIImageView,:logodoframe[[0,0],[320,568]]end# you can set the size to fill horizontally and keep the aspect ratioaddUIImageView,:logodoframe[[0,0],['100%',:scale]]end# many other examples hereaddUIView,:button_containerdo# Like I said, the frame method is very powerful. It will try to# apply the correct autoresizingMask for you; the from_bottom method will# set the UIAutoresizingMask to "FlexibleTop", and using '100%' in the# width will ensure the frame stays the width of its parent.framefrom_bottom(height: 50,width: '100%')framefrom_bottom(h: 50,w: '100%')# is fine, too# same as above; assumes full widthframefrom_bottom(height: 50)# views added inside a block are added to that# container. You can reference the container with 'superview', but then# you're working on the object directly, so no method translation (foo_bar# => fooBar) will be done for you.addUIButton,:login_buttondobackground_colorsuperview.backgroundColor# 'parent' is not instance of a view; it's a special object that# acts like a placeholder for various values. If you want to assign# *any* superview property, use 'superview' instead. 'parent' is mostly# useful for setting the frame.frame[[10,5],[50,parent.height - 10]]endendaddUIView,:inputsdoframex: 0,y: 0,width: '100%',height: '100% - 50'# setting autoresizing_mask should handle rotation eventsautoresizing_mask:pin_to_top,:flexible_height,:flexible_width# we'll use 'sizeToFit' to calculate the heightaddUITextField,:username_inputdoframe[[10,10],['100% - 10',:auto]]endaddUITextField,:password_inputdoframebelow(:username_input,down: 8)endendendendIn MotionKit, when you define a method that has the same name as a view stylename with the suffix "_style", that method is called and is expected to style that view.
classLoginLayout < MK::LayoutdeflayoutaddUIImageView,:logodo# this can be moved into `logo_style` below:frame[[0,0],['100%',:scale]]endaddUIView,:button_containerenddeflogo_styleframe[[0,0],['100%',:scale]]imageUIImage.imageNamed('logo')enddefbutton_container_stylebackground_colorUIColor.clearColorend# In case you're curious, the MK::Layout#initialize method takes no arguments.# Just be sure to call `super`definitializesuper# ...endendSo as an additional code-cleanup step, why not put those methods in a module, and include them in your layout! Sounds clean and organized to me! You can include multiple stylesheets this way, just be careful around name collisions.
# app/styles/login_styles.rbmoduleLoginStylesdeflogin_button_style# this example uses SugarCube to create UIColor and CGColor objects.background_color'#51A8E7'.uicolortitle'Log In'# `layer` returns a CALayer, which in turn becomes the new context inside# this blocklayerdocorner_radius7.0shadow_color'#000000'.cgcolorshadow_opacity0.9shadow_radius2.0shadow_offset[0,0]endendend# back in our LoginLayout classclassLoginLayoutincludeLoginStylesdeflayoutaddUIButton,:login_button# ...endendIf you have a very complicated layout that you want to break up into child layouts, that is supported as well:
classParentLayout < MK::LayoutdeflayoutaddChildLayout,:child_idendendThe id is (as always) optional, but allows you to fetch the layout using
get(id).
layout.get(:child_id)# => ChildLayoutCalling get(:child_id).view will return the view associated with that
layout.
If you need to use a custom root view, you can use the root method from within
the layout method. When you create or assign the root view this way, you must
assign subviews and styles inside a block that you pass to root.
deflayoutroot(SomeOtherViewclass)doaddUILabelendendYou can also pass in a root view to your layout, like this:
defloadView@layout=MyLayout.new(root: self.view).buildendMake sure to call .build; otherwise, the layout will be returned but the view not built.
In this case, if you want to style the root view, just refer to it in your layout:
deflayoutroot:my_root_viewdo# ...endenddefmy_root_view_stylebackground_colorUIColor.grayColorendThis is especially useful with collection views, table views, and table cells, where you can assign a root view explicitly:
returnMyCellLayout.new(root: cell).buildKeep in mind that MotionKit will not retain a strong reference when you provide a root view, so retain it yourself to prevent it from being deallocated.
If you've used RMQ's Stylers, you'll recognize a very similar pattern here. In
RMQ the 'style' methods are handed a 'Styler' instance, which wraps access to
the view. In MotionKit we make use of method_missing to call these methods
indirectly. That takes care of most methods related to styling, but you might
want to write some "helper" methods so that your styling code is more concise.
Some examples are included in the MotionKit core, but the SweetKit gem has
many more. If you are writing helpers for UIKit or AppKit, please consider
adding them to SweetKit, so we can all share in the productivity boost! 😃
deflogin_label_styletext'Press me'# this gets delegated to UILabel#textend# It's not hard to add extensions for common tasks, like setting the "normal"# title on a UIButtondeflogin_button_styletitle'Press me'# this gets delegated to UIButtonHelpers#title(title), which in turn calls# button.setTitle(title, forState: UIControlStateNormal)# See uibutton_helpers.rb for implementation.endMotionKit offers shortcuts and mini-DSLs for frames, auto-layout, and
miscellaneous helpers. But if a method is not defined, it is sent to the view
after a little introspection. If you call a method like title_color value, MotionKit
will try to call:
setTitle_color(value)title_color=(value)title_color(value)- (try again, converting to camelCase)
setTitleColor(value)titleColor=(value)titleColor(value)- (failure:)
raise NoMethodError
deflogin_button_stylebackground_colorUIColor.clearColor# this gets converted to `self.target.backgroundColor = ...`endIntrospection and method_missing add a little overhead to your code, but in our benchmarking it is insignificant and undetectable. Let us know if you find any performance issues.
You can easily add your own helpers to MotionKit. They
should all be named consistenly, e.g. MotionKit::UIViewHelpers,
MotionKit::UILabelHelpers, etc. You just need to specify the "target class" that
your helper class is meant to work with. Each class can only have one helper
class.
moduleMotionKit# these helpers will only be applied to instances of UILabel and UILabel# subclassesclassUILabelHelpers < UIViewHelperstargetsUILabel# style methods can accept any number of arguments, and a block. The current# view should be referred to via the method `target`defcolor(color)target.textColor=colorend# If a block is passed it is your responsibility to call `context(val, &block)`# if that is appropriate. I'll use `UIView#layer` as an example,# but actually if you pass a block to a method that returns an object, that# block will be called with that object as the context.deflayer(&block)context(target.layer, &block)end# Sure, you can add flow-control mechanisms if that's your thing!## You can use the block to conditionally call code; on iOS there are# orientation helpers `portrait`, `landscape`, etc that apply styles based# on the current orientation.defsometimes(&block)ifrand > 0.5yieldendendendendFor your own custom classes, or when you want to write
helper methods for a built-in class, you will need to write a class that
"targets" that class. This will be a subclass of MK::UIViewHelpers; it looks
and feels like a MK::Layout subclass, but these classes are used to extend
the MotionKit DSL, and should not be instantiated or used to build layouts.
Again, to be clear: you should be subclassing MK::Layout when you build your
controller layouts, and you should write a subclass of MK::UIViewHelpersonly
when you are adding extensions to the MotionKit DSL.
# Be sure to extend an existing Helpers class, otherwise you'll lose a lot of# functionality. Often this will be `MK::UIViewHelpers` on iOS and# `MK::NSViewHelpers` on OS X.classCustomViewHelpers < MK::UIViewHelperstargetsCustomViewdeffore_color(value)target.foregroundColor=valueendend...is in the READMORE document. I re-explain some of these topics, go into some more detail, that kinda thing. Basically an overflow document for topics I don't want to stuff into the README.
These are all built-in, unless otherwise specified.
There are lots of frame helpers for NSView and UIView subclasses. It's cool that you can set position and sizes as percents, but scroll down to see examples of setting frames based on any other view. These are super useful! Most of the ideas, method names, and some code came straight out of geomotion. It's not quite as powerful as geomotion, but it's close!
One advantage over geomotion is that many of these frame helpers accept a view or view name, so that you can place the view relative to that view.
# most direct way to set the frame, using pt valuesframe[[0,0],[320,568]]# using sizes relative to superviewframe[[5,5],['100% - 10pt','100% - 10pt']]# the 'pt' suffix is optional, and ignored. in the future we could add support# for other suffixes - would that even be useful? probably not...# other available methods:origin[5,5]x5# aka left(..)right5# right side of the view is 5px from the left side of the superviewbottom5# bottom of the view is 5px from the top of the superviewsize['100% - 10','100% - 10']width'100% - 10'# aka w(...)height'100% - 10'# aka h(...)size['90%','90%']center['50%','50%']######### +--------------------------------------------------+# |from_top_left from_top from_top_right|# | |# |from_left from_center from_right|# | |# |from_bottom_left from_bottom from_bottom_right|# +--------------------------------------------------+You can position the view relative to other views, either the superview or any
other view. You must pass the return value to frame.
# If you don't specify a view to base off of, the view is positioned relative to# the superview:framefrom_bottom_right(size: [100,100])# 100x100 in the BR cornerframefrom_bottom(size: ['100%',32])# full width, 32pt heightframefrom_top_right(left: 5)# But if you pass a view or symbol as the first arg, the position will be# relative to that viewfrom_top_right(:info_container,left: 5)######### above# +---+# left_of | | right_of# (before) | | (after)# +---+# below# these methods *require* another view.frameabove(:foo,up: 8)frameabove(:foo,up: 8)framebefore(:foo,left: 8)framerelative_to(:foo,down: 5,right: 5)# it's not common, but you can also pass a view to any of these methodsfoo=self.get(:foo)framefrom_bottom_left(foo,up: 5,left: 5)You can pass symbols like autoresizing_mask :flexible_width, or use
symbols that have more intuitive meaning than the usual
UIViewAutoresizingFlexible* constants. These work in iOS and OS X.
All of the :pin_to_ shorthands have a fixed size, whereas the :fill_
shorthands have flexible size.
# the :fill shorthands will get you a ton of mileageautoresizing_mask:fill_top# but if you want the size to stay constant, use :pin_toautoresizing_mask:pin_to_bottom# or, a list of flexible sidesautoresizing_mask:flexible_right,:flexible_bottom,:flexible_width# or, combine them in some crazy fancy wayautoresizing_mask:pin_to_left,:rigid_top# 'rigid' undoes a 'flexible' settingflexible_left: Stickstotherightsideflexible_width: Widthvarieswithparentflexible_right: Stickstotheleftsideflexible_top: Stickstothebottomflexible_height: Heightvarieswithparentflexible_bottom: Stickstothetoprigid_left: Leftsidestaysconstant(undoes:flexible_left)rigid_width: Widthstaysconstant(undoes:flexible_width)rigid_right: Rightsidestaysconstant(undoes:flexible_right)rigid_top: Topstaysconstant(undoes:flexible_top)rigid_height: Heightstaysconstant(undoes:flexible_height)rigid_bottom: Bottomstaysconstant(undoes:flexible_bottom)fill: Thesizeincreaseswithanincreaseinparentsizefill_top: Widthvarieswithparentandviewstickstothetopfill_bottom: Widthvarieswithparentandviewstickstothebottomfill_left: Heightvarieswithparentandviewstickstotheleftfill_right: Heightvarieswithparentandviewstickstotherightpin_to_top_left: Viewstaysintop-leftcorner,sizedoes not change.pin_to_top: Viewstaysintop-center,sizedoes not change.pin_to_top_right: Viewstaysintop-rightcorner,sizedoes not change.pin_to_left: Viewstayscenteredontheleft,sizedoes not change.pin_to_center: Viewstayscentered,sizedoes not change.pin_to_right: Viewstayscenteredontheright,sizedoes not change.pin_to_bottom_left: Viewstaysinbottom-leftcorner,sizedoes not change.pin_to_bottom: Viewstaysinbottom-center,sizedoes not change.pin_to_bottom_right: Viewstaysinbottom-rightcorner,sizedoes not change.Inside a constraints block you can use similar helpers as above, but you'll
be using Cocoa's Auto Layout system instead. This is the recommended way to set
your frames, now that Apple is introducing multiple display sizes. But beware,
Auto Layout can be frustrating... :-/
Here are some examples to get started:
constraintsdotop_leftx: 5,y: 10# the MotionKit::Constraint class has lots of aliases and "smart" methods,# so you can write very literate code:top_left.equals([5,10])top_left.is([5,10])top_left.is.equal_to(x: 5,y: 10)top_left.is == {x: 5,y: 10}top_left.is >= {x: 5,y: 10}top_left.is <= {x: 5,y: 10}# this is all the same as setting these two constraints:x5# aka `left 5`y10# aka `top 10`# You can have multiple constraints on the same property, and if the# priorities are set appropriately you can easily have minimum margins,# minimum widths, that kind of thing:x.is.at_least(10).priority(:required)x.is(15).priority(:low)width.is.at_least(100).priority(:required)width.is(150).priority(:low)# using the `Constraint#is` method you can even use ==, <= and >=x.is >= 10x.is == 15# setting the priority:(x.is >= 10).priority(:required)(x.is == 15).priority(:low)# setting the identifierx.equals(15).identifier('foo')endBut of course with AutoLayout you set up relationships between views. Using the element-id as a placeholder for a view works especially well here.
constraintsdotop_left.equalsx: 5,y: 5# this sets the origin relative to the superviewtop_left.equals(:superview).plus([5,5])# this will do the same thing!width.equals(:foo).minus(10)# searches for a view named :fooheight.equals(:foo).minus(10)# that's repetitive, so just set 'size'size.equals(:foo).minus(10)size.equals(:foo).minus([10,15])# 10pt thinner, 15pt shorter# if you are using a view that has a sensible intrinsic size, like an image,# you can use :scale to have the width or height adjusted according to the# other sizewidth.equals(:superview)height(:scale)# scale the height according to the widthendJust like with frame helpers you can use the :element_id to refer to another
view, but get this: the view need not be created yet! This is because when you
setup a constraints block, it isn't resolved immediately; the symbols are
resolved at the end. This feature uses the deferred method behind the scenes
to accomplish this.
addUIView,:foodoconstraintsdowidth.equals(:bar).plus(10)# :bar has not been added yet!endendaddUIView,:bardoconstraintsdowidth.equals(:foo).minus(10)width.equals(100).minus(10)# believe it or not, this ^ code works! AutoLayout is a strange beast; it's# not an "imperative" system, it solves a system of equations. In this# case, :bar will have width 110, and :foo will have width 100, because# those values solve these equations:# foo.width = 100# foo.width = bar.width - 10# foo.width = bar.width + 10# If you have constraints that conflict you'll get error messages or# nonsensical values.# There are helpers that act as placeholders for views, if you have multiple# views with the same name:# first, last, nthwidth.equals(last(:foo))width.equals(first(:foo))width.equals(nth(:foo,5))endendOne common use case is to use a child layout to create many instances of the
same layout that repeat, for instance a "row" of content. In this case you will
probably have many views with the same id, and you will not know the index of
the container view that you want to add constraints to. In this situation, use
the nearest, prev or next method to find a container, sibling, or
child view.
prev and next are easy; they just search for a sibling view. No
superviews or subviews are searched.
nearest will search child views, siblings, and superviews, in that order. The
"distance" is calculated as such:
- the current view
- subviews
- siblings
- superview
- superview's siblings, or a child of the sibling (depth-first search)
- continue up the tree
See the AutoLayout sample app for an example of this usage.
items.eachdo |item|
addUIView,:rowdoaddUIImageView,:avataraddUILabel,:titleendenddeftitle_styleconstraintsdo# center the view verticallycenter.equals(nearest(:row))# and place it to the right of the :avatarleft.equals(nearest(:avatar),:right).plus(8)right.equals(nearest(:row)).minus(8)endendOne pain point in working with constraints is determining when to add them to your views. We tried really hard to figure out a way to automatically add them, but it's just an untenable problem (Teacup suffers from a similar conundrum).
Essentially, the problem comes down to this: you will often want to set
constraints that are related to the view controller's view, but those must be
created/set aftercontroller.view = @layout.view. Without doing some crazy
method mangling on NS/UIView we just can't do this automatically
Long story short: If you need to create constraints that refer to the controller view, you need to use a separate method that is called after the view hierarchy is created.
classMainLayout < MK::LayoutdeflayoutaddUILabel,:labeldoconstraintsdox0width('100%')endendend# You should call this method from `UIViewController#updateViewConstraints`# and pass in your controllerdefadd_constraints(controller)# guard against adding these constraints more than onceunless@layout_constraints_added@layout_constraints_added=trueconstraints(:label)dotop.equals(controller.topLayoutGuide)endendendendclassMainController < UIViewControllerdefloadView@layout=MainLayout.newself.view=@layout.viewend# for the constraints to work reliably they should be added in this method:defupdateViewConstraints@layout.add_constraints(self)superendendIt might feel natural to treat constraints as "frame setters", but they are persistent objects that are attached to your views. This means if you create new constraints, like during a screen rotation, your old constraints don't “go away”. For example:
deflabel_styleportraitdoleft10endlandscapedoleft15# adds *another* constraint on the left attribute - in addition to the `left 10` constraint!endendInstead, you should retain the constraint and make changes to it directly:
constraintsdo@label_left_constraint=left10end# reapply blocks are called via the Layout#reapply! method.reapplydoportraitdo@label_left_constraint.equals10endlandscapedo@label_left_constraint.equals15endendIf you want to animate a constraint change, you can use layoutIfNeeded from
within a UIView animation block. The sample app "Chatty" does this to move a
text field when the keyboard is displayed. kbd_height is the height of the
keyboard.
@container_bottom.minuskbd_height# set @container_bottom.constant = 0 when the keyboard disappearsUIView.animateWithDuration(duration,delay: 0,options: curve,animations: ->doself.view.layoutIfNeeded# applies the constraint changeend,completion: nil)gem install motion-kit-events
Adds on :event and trigger :event methods to MK::Layout objects. These
can be used to send events from the Layout to your controller, further
simplifying the controller code (and usually making it more testable). See the
MotionKit::Events documentation for more information.
gem install motion-kit-templates
Adds project templates, for use with motion create.
motion create foo --template=mk-ios
motion create foo --template=mk-osx
These are available on iOS.
addUIView,:containerdoportraitdoframefrom_top(width: '100%',height: 100)endlandscapedoframefrom_top_left(width: 300,height: 100)endendIn your style methods, you can register blocks that get called during "restyling", which is usually triggered by a rotation change (though, if you're making good use of autoresizingMask or AutoLayout constraints, you should not have to do this, right?).
It's important to note that the style methods are not actually called again. The
blocks are retained on the view, along with the "context", and calling
reapply! calls all those blocks with the context set as you'd expect.
If you have code that you want to be called during initialization and during
reapply, use the always helper:
deflogin_button_style# only once, when the layout is first being createdtitle'initial title'# only during reapplyreapplydotitle'something happened!'end# applied every timealwaysdotitle'You win!'endendOr, you might need to set a frame or other property based on a view that hasn't
been created yet. In this case, you can use deferred to have a block of code
run after the current layout is completed.
deflogin_button_styledeferreddoframebelow(last(:label),height: 20)endendmoduleAppStylesdefrounded_buttonlayerdocorner_radius7masks_to_boundstrueendendendclassLoginLayout < MotionKit::LayoutincludeAppStylesdeflayoutaddbutton,:login_buttonenddeflogin_button_styleself.rounded_buttontitle'Login'endendThe SweetKit gem combines MotionKit and SugarCube. The helpers it provides allow for even more expressiveness, for instance:
addUITextFielddoreturn_key_type:emailtext_alignment:rightendThe OS X helpers are really nice, because it tries to hide most of the annoying subtletees of the NSCell/NSControl dichotomy.
gem install sweet-kit
When you use the add method to add a subview, that view will be retained by
the Layout even if you remove it from the view hierarchy. If you want the
Layout to forget all about the view, call remove(view) (which also calls
removeFromSuperview) or forget(element_id) (which only removes it from the
Layout) on the Layout.
We welcome your contributions! Please be sure to run the specs before you do, and consider adding support for both iOS and OS X.
To run the specs for both platforms, you will need to run rake spec twice:
> rake spec # runs iOS specs
> rake spec platform=osx # OS X specs
by colinta
If you've worked with XIB/NIB files, you might know that while they can be cumbersome to deal with, they have the great benefit of keeping your controllers free of layout and styling concerns. Teacup brought some of this benefit, in the form of stylesheets, but you still built the layout in the body of your controller file. This needed to be fixed.
Plus Teacup is a beast! Imported stylesheets, orientation change events, auto-layout support. It's got a ton of features, but with that comes a lot of complexity. This has led to an unfortunate situation - I'm the only person who understands the code base! This was never the intention of Teacup. It started out as, and was always meant to be, a community project, with contributions coming from all of its users.
When ProMotion and later RMQ were released, they both included their own styling mechanisms. Including Teacup as a dependency would have placed a huge burden on their users, and they would have had to ensure compatibility. Since Teacup does a lot of method swizzling on base classes, this is not a trivial undertaking.
If you use RMQ or ProMotion already, you'll find that MotionKit fits right in. We designed it to be something that can easily be brought into an existing project, too; it does not extend any base classes, so it's completely opt-in.
Unlike Teacup, you won't have your styles reapplied due to orientation changes, but it's really easy to set that up, as you'll see. Or, use AutoLayout (the DSL is better than Teacup's, I think) and you'll get orientation changes for free!
Big thanks to everyone who contributed on this project! I hope it serves you as well as Teacup, and for even longer into the future.
Sincerely,
Colin T.A. Gray Feb 13, 2014