diff --git a/README.md b/README.md index f12ad49..6106cbd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,25 @@ Using stylesheets and layouts, it makes coding an iOS app like designing a websi **Check out a working sample app [here](https://github.com/rubymotion/teacup/tree/master/samples/Hai)!** +#### Installation + +First get the teacup library into your local project using git submodules: + +```bash +$ git submodule add https://github.com:rubymotion/teacup vendor/teacup +``` + +Then add the teacup library to your Rakefile: +``` + # Add libraries *before* your app so that you can access constants they define safely + # + dirs = ['vendor/teacup/lib', 'app'] + + # require all the directories in order. + app.files = dirs.map{|d| Dir.glob(File.join(app.project_dir, "#{d}/**/*.rb")) }.flatten +``` + + #### Showdown Regular diff --git a/lib/layout.rb b/lib/layout.rb new file mode 100644 index 0000000..98850fc --- /dev/null +++ b/lib/layout.rb @@ -0,0 +1,207 @@ +module Teacup + # Teacup::Layout defines a layout function that can be used to configure the + # layout of views in your application. + # + # It is included into UIView and UIViewController directly so these functions + # should be available when you need them. + # + # In order to use layout() in a UIViewController most effectively you will want + # to define a stylesheet method that returns a stylesheet. + # + # @example + # class MyViewController < UIViewController + # interface(:my_view) do + # layout UIImage, :logo + # end + # + # def stylesheet + # Teacup::Stylesheet::Logo + # end + # end + # + module Layout + + # Alter the layout of a view + # + # @param instance The first parameter is the view that you want to + # layout. + # + # @param name The second parameter is optional, and is the + # stylename to apply to the element. When using + # stylesheets any properties defined in the + # current stylesheet (see {stylesheet}) for this + # element will be immediately applied. + # + # @param properties The third parameter is optional, and is a Hash + # of properties to apply to the view directly. + # + # @param &block If a block is passed, it is evaluated such that + # any calls to {subview} that occur within that + # block cause created subviews to be added to *this* + # view instead of to the top-level view. + # + # For example, to alter the width and height of a carousel: + # + # @example + # layout(carousel, width: 500, height: 100) + # + # Or to layout the carousel in the default style: + # + # @example + # layout(carousel, :default_carousel) + # + # You can also use this method with {subview}, for example to add a new + # image to a carousel: + # + # @example + # layout(carousel) { + # subview(UIImage, backgroundColor: UIColor.colorWithImagePattern(image) + # } + # + def layout(instance, name_or_properties, properties_or_nil=nil, &block) + if properties_or_nil + name = name_or_properties.to_sym + properties = properties_or_nil + elsif Hash === name_or_properties + name = nil + properties = name_or_properties + else + name = name_or_properties.to_sym + properties = nil + end + + instance.stylesheet = stylesheet + instance.style(properties) if properties + instance.stylename = name if name + + begin + superview_chain << instance + instance_exec(instance, &block) if block_given? + ensure + superview_chain.pop + end + + instance + end + + # Add a new subview to the view heirarchy. + # + # By default the subview will be added at the top level of the view heirarchy, though + # if this function is executed within a block passed to {layout} or {subview}, then this + # view will be added as a subview of the instance being layed out by the block. + # + # This is particularly useful when coupled with the {UIViewController.heirarchy} function + # that allows you to declare your view heirarchy. + # + # @param class_or_instance The UIView subclass (or instance thereof) that you want + # to add. If you pass a class, an instance will be created + # by calling {new}. + # + # @param *args Arguments to pass to {layout} to instruct teacup how to + # lay out the newly added subview. + # + # @param &block A block to execute with the current view context set to + # your new element, see {layout} for more details. + # + # @return instance The instance that was added to the view heirarchy. + # + # For example, to specify that a controller should contain some labels: + # + # @example + # MyViewController < UIViewController + # heirarchy(:my_view) do + # subview(UILabel, text: 'Test') + # subview(UILabel, :styled_label) + # end + # end + # + # If you need to add a new image at runtime, you can also do that: + # + # @example + # layout(carousel) { + # subview(UIImage, backgroundColor: UIColor.colorWithImagePattern(image) + # } + # + def subview(class_or_instance, *args, &block) + instance = Class === class_or_instance ? class_or_instance.new : class_or_instance + + if Class === class_or_instance + unless class_or_instance <= UIView + raise "Expected subclass of UIView, got: #{class_or_instance.inspect}" + end + instance = class_or_instance.new + elsif UIView === class_or_instance + instance = class_or_instance + else + raise "Expected a UIView, got: #{class_or_instance.inspect}" + end + + (superview_chain.last || top_level_view).addSubview(instance) + + layout(instance, *args, &block) + + instance + end + + # Returns a stylesheet to use to style the contents of this controller's + # view. + # + # This method will be queried each time {restyle!} is called, and also + # implicitly # whenever Teacup needs to draw your layout (currently only at + # view load time). + # + # @return Teacup::Stylesheet + # + # @example + # + # def stylesheet + # if [UIDeviceOrientationLandscapeLeft, + # UIDeviceOrientationLandscapeRight].include?(UIDevice.currentDevice.orientation) + # Teacup::Stylesheet::IPad + # else + # Teacup::Stylesheet::IPadVertical + # end + # end + def stylesheet + nil + end + + # Instruct teacup to reapply styles to your subviews + # + # You should call this whenever the return value of your stylesheet meethod + # would change, + # + # @example + # def willRotateToInterfaceOrientation(io, duration: duration) + # restyle! + # end + def restyle! + top_level_view.stylesheet = stylesheet + end + + protected + + # Get's the top-level UIView for this object. + # + # This can either be 'self' if the current object is in fact a UIView, + # or 'view' if it's a controller. + # + # @return UIView + def top_level_view + case self + when UIViewController + view + when UIView + self + end + end + + # Get's the current stack of views in nested calls to layout. + # + # The view at the end of the stack is the one into which subviews + # are currently being attached. + def superview_chain + @superview_chain ||= [] + end + end +end diff --git a/lib/style_sheet.rb b/lib/style_sheet.rb index b892fc9..63b952b 100644 --- a/lib/style_sheet.rb +++ b/lib/style_sheet.rb @@ -1,143 +1,143 @@ module Teacup - # StyleSheets in Teacup act as a central configuration mechanism, + # Stylesheets in Teacup act as a central configuration mechanism, # they have two aims: # # 1. Allow you to store "Details" away from the main body of your code. # (controllers shouldn't have to be filled with style rules) # 2. Allow you to easily re-use configuration in many places. # - # The API really provides only two methods, {StyleSheet#style} to store properties - # on the StyleSheet; and {StyleSheet#query} to get them out again: + # The API really provides only two methods, {Stylesheet#style} to store properties + # on the Stylesheet; and {Stylesheet#query} to get them out again: # # @example - # sheet = Teacup::StyleSheet.new - # sheet.style :buttons, :corners => :rounded + # stylesheet = Teacup::Stylesheet.new + # stylesheet.style :buttons, :corners => :rounded # # => nil - # sheet.query :buttons + # stylesheet.query :buttons # # => {:corners => :rounded} # # In addition to this, two separate mechanisms are provided for sharing - # configuration within sheets. + # configuration within stylesheets. # - # Firstly, if you set the ':like' property for a given query, then on lookup - # the StyleSheet will merge the properties for the ':like' query into the return - # value. Conflicts are resolved so that properties with the original query + # Firstly, if you set the ':extends' property for a given stylename, then on lookup + # the Stylesheet will merge the properties for the ':extends' stylename into the return + # value. Conflicts are resolved so that properties with the original stylename # are resolved in its favour. # # @example - # Teacup::StyleSheet.new(:IPad) do + # Teacup::Stylesheet.new(:IPad) do # style :button, # background: UIColor.blackColor, # top: 100 # - # style :ok_button, like: :button, + # style :ok_button, extends: :button, # title: "OK!", # top: 200 # # end - # Teacup::StyleSheet::IPad.query(:ok_button) + # Teacup::Stylesheet::IPad.query(:ok_button) # # => {background: UIColor.blackColor, top: 200, title: "OK!"} # - # Secondly, you can include StyleSheets into each other, in exactly the same way as you + # Secondly, you can import Stylesheets into each other, in exactly the same way as you # can include Modules into each other in Ruby. This allows you to share rules between - # StyleSheets. + # Stylesheets. # - # As you'd expect, conflicts are resolve so that the StyleSheet on which you call query + # As you'd expect, conflicts are resolve so that the Stylesheet on which you call query # has the highest precedence. # # @example - # Teacup::StyleSheet.new(:IPad) do + # Teacup::Stylesheet.new(:IPad) do # style :ok_button, # title: "OK!" # end # - # Teacup::StyleSheet.new(:IPadVertical) do - # include :IPad + # Teacup::Stylesheet.new(:IPadVertical) do + # import :IPad # style :ok_button, # width: 80 # end - # Teacup::StyleSheet::IPadVertical.query(:ok_button) + # Teacup::Stylesheet::IPadVertical.query(:ok_button) # # => {title: "OK!", width: 80} # # The two merging mechanisms are considered independently, so you can override - # a property both in a ':like' rule, and also in an included StyleSheet. In such a - # a case the StyleSheet inclusion conflicts are resolved independently; and then in - # a second phase, the ':like' chain is flattened. + # a property both in a ':extends' rule, and also in an imported Stylesheet. In such a + # a case the Stylesheet inclusion conflicts are resolved independently; and then in + # a second phase, the ':extends' chain is flattened. # - class StyleSheet + class Stylesheet attr_reader :name - # Create a new StyleSheet with the given name. + # Create a new Stylesheet with the given name. # # @param name, The name to give. - # @param &block, The body of the StyleSheet instance_eval'd. + # @param &block, The body of the Stylesheet instance_eval'd. # @example - # Teacup::StyleSheet.new(:IPadVertical) do - # include :IPadBase + # Teacup::Stylesheet.new(:IPadVertical) do + # import :IPadBase # style :continue_button, # top: 50 # end # def initialize(name, &block) @name = name.to_sym - Teacup::StyleSheet.const_set(@name, self) + Teacup::Stylesheet.const_set(@name, self) instance_eval &block self end - # Include another StyleSheet into this one, the rules defined + # Include another Stylesheet into this one, the rules defined # within it will have lower precedence than those defined here # in the case that they share the same keys. # - # @param Symbol the name of the sheet. + # @param Symbol the name of the stylesheet. # @example - # Teacup::StyleSheet.new(:IPadVertical) do - # include :IPadBase - # include :VerticalTweaks + # Teacup::Stylesheet.new(:IPadVertical) do + # import :IPadBase + # import :VerticalTweaks # end - def include(name_or_sheet) - if StyleSheet === name_or_sheet - included << name_or_sheet.name + def import(name_or_stylesheet) + if Stylesheet === name_or_stylesheet + imported << name_or_stylesheet.name else - included << name_or_sheet.to_sym + imported << name_or_stylesheet.to_sym end end - # Add a set of properties for a given query or queries. + # Add a set of properties for a given stylename or set of stylenames. # - # @param Symbol, *query + # @param Symbol, *stylename # @param Hash[Symbol, Object], properties # @example - # Teacup::StyleSheet.new(:IPadBase) do + # Teacup::Stylesheet.new(:IPadBase) do # style :pretty_button, # backgroundColor: UIColor.blackColor # - # style :continue_button, like: :pretty_button, + # style :continue_button, extends: :pretty_button, # title: "Continue!", # top: 50 # end def style(*queries) properties = queries.pop - queries.each do |query| - styles[query].update(properties) + queries.each do |stylename| + styles[stylename].update(properties) end end - # Get the properties defined for the given query, in this StyleSheet and all - # those that are included. + # Get the properties defined for the given stylename, in this Stylesheet and all + # those that have been imported. # - # If the ':like' property is set, we then repeat the process with the value + # If the ':extends' property is set, we then repeat the process with the value # of that, and include them into the result with lower precedence. # - # @param Symbol query, the query to look up. + # @param Symbol stylename, the stylename to look up. # @return Hash[Symbol, *] the resulting properties. # @example - # Teacup::StyleSheet::IPadBase.query(:continue_button) + # Teacup::Stylesheet::IPadBase.query(:continue_button) # # => {backgroundColor: UIColor.blackColor, title: "Continue!", top: 50} - def query(query) - this_rule = properties_for(query) + def query(stylename) + this_rule = properties_for(stylename) - if also_include = this_rule.delete(:like) + if also_include = this_rule.delete(:extends) query(also_include).merge(this_rule) else this_rule @@ -148,44 +148,44 @@ def query(query) # # @return String def inspect - "Teacup::StyleSheet:#{name.inspect}" + "Teacup::Stylesheet:#{name.inspect}" end protected - # Get the properties for a given query, including any properties - # defined on sheets that have been included into this one, but not - # resolving ':like' inheritance. + # Get the properties for a given stylename, including any properties + # defined on stylesheets that have been imported into this one, but not + # resolving ':extends' inheritance. # - # @param Symbol query, the query to search for. - # @param Hash so_far, the properties already found in sheets with + # @param Symbol stylename, the stylename to search for. + # @param Hash so_far, the properties already found in stylesheets with # lower precedence than this one. - # @param Hash seen, the StyleSheets that we've already visited, this is + # @param Hash seen, the Stylesheets that we've already visited, this is # to avoid pathological cases where stylesheets - # have been included recursively. + # have been imported recursively. # @return Hash - def properties_for(query, so_far={}, seen={}) + def properties_for(stylename, so_far={}, seen={}) return so_far if seen[self] seen[self] = true - included.each do |name| - unless Teacup::StyleSheet.const_defined?(name) - raise "Teacup tried to include StyleSheet:#{name} into StyleSheet:#{self.name}, but it didn't exist" + imported.each do |name| + unless Teacup::Stylesheet.const_defined?(name) + raise "Teacup tried to import Stylesheet:#{name} into Stylesheet:#{self.name}, but it didn't exist" end - Teacup::StyleSheet.const_get(name).properties_for(query, so_far, seen) + Teacup::Stylesheet.const_get(name).properties_for(stylename, so_far, seen) end - so_far.update(styles[query]) + so_far.update(styles[stylename]) end - # The list of StyleSheet names that have been included in this one. + # The list of Stylesheet names that have been imported into this one. # # @return Array[Symbol] - def included - @included ||= [] + def imported + @imported ||= [] end - # The actual contents of this sheet as a Hash from query to properties. + # The actual contents of this stylesheet as a Hash from stylename to properties. # # @return Hash[Symbol, Hash] def styles diff --git a/lib/teacup.rb b/lib/teacup.rb deleted file mode 100644 index 99eb7e0..0000000 --- a/lib/teacup.rb +++ /dev/null @@ -1,70 +0,0 @@ -module Teacup - - # - - - - - - - - - - - - - - - - - - - - # Config - # - - - - - - - - - - - - - - - - - - - - - class << self - - def create(query) - properties = current_sheet.query(query) - constructor = properties.delete(:class) - - instance = if Proc === constructor - instance = constructor.call - else - instance = constructor.new - end - - apply_properties(properties, instance) - instance - end - - def style(query, instance) - apply_properties(current_sheet.query(query), instance) - instance - end - - def apply_properties(properties, instance) - clean_properties! properties - - properties.each do |key, value| - if key == :title && UIButton === instance - instance.setTitle(value, forState: UIControlStateNormal) - elsif instance.respond_to?(:"#{key}=") - instance.send(:"#{key}=", value) - else - $stderr.puts "Teacup WARN: Can't apply #{key} to #{instance.inspect}" - end - end - end - - def clean_properties!(properties) - return unless [:frame, :left, :top, :width, :height].any?(&properties.method(:key?)) - - frame = properties.delete(:frame) || [[0,0],[0,0]] - - frame[0][0] = properties.delete(:left) || frame[0][0] - frame[0][1] = properties.delete(:top) || frame[0][1] - frame[1][0] = properties.delete(:width) || frame[1][0] - frame[1][1] = properties.delete(:height) || frame[1][1] - - properties[:frame] = frame - end - - def update(view) - style(view.className, view) - - view.subviews.each(&method(:update)) - end - - def current_sheet - if UIDevice.currentDevice.orientation == UIDeviceOrientationLandscapeLeft || - UIDevice.currentDevice.orientation == UIDeviceOrientationLandscapeRight - Teacup::StyleSheet::IPad - else - Teacup::StyleSheet::IPadVertical - end - end - end -end diff --git a/lib/view.rb b/lib/view.rb new file mode 100644 index 0000000..f63e6ed --- /dev/null +++ b/lib/view.rb @@ -0,0 +1,123 @@ +module Teacup + # Teacup::View defines some utility functions for UIView that enable + # a lot of the magic for Teacup::Layout. + # + # Most users of teacup should be able to ignore the contents of this file + # for the most part. + module View + # The current stylename that is used to look up properties in the stylesheet. + attr_reader :stylename + + # The current stylesheet will be looked at when properties are needed. + attr_reader :stylesheet + + # Alter the stylename of this view. + # + # This will cause new styles to be applied from the stylesheet. + # + # @param Symbol stylename + def stylename=(stylename) + @stylename = stylename + style(stylesheet.query(stylename)) if stylesheet + end + + # Alter the stylesheet of this view. + # + # This will cause new styles to be applied using the current stylename, + # and will recurse into subviews. + # + # If you would prefer that a given UIView object does not inherit the + # stylesheet from its parents, override the 'stylesheet' method to + # return the correct value at all times. + # + # @param Teacup::Stylesheet stylesheet. + def stylesheet=(stylesheet) + @stylesheet = stylesheet + style(stylesheet.query(stylename)) if stylename && stylesheet + subviews.each{ |subview| subview.stylesheet = stylesheet } + end + + # Animate a change to a new stylename. + # + # This is equivalent to wrapping a call to .stylename= inside + # UIView.beginAnimations. + # + # @param Symbol the new stylename + # @param Options the options for the animation (may include the + # duration and the curve) + # + def animate_to_stylename(stylename, options={}) + return if self.stylename == stylename + UIView.beginAnimations(nil, context: nil) + # TODO: This should be in a style-sheet! + UIView.setAnimationDuration(options[:duration]) if options[:duration] + UIView.setAnimationCurve(options[:curve]) if options[:curve] + self.stylename = stylename + UIView.commitAnimations + end + + # Apply style properties to this element. + # + # Takes a hash of properties such as may have been read from a stylesheet + # or passed as parameters to {Teacup::Layout#layout}, and applies them to + # the element. + # + # Does a little bit of magic (that may be split out as 'sugarcube') to + # make properties work as you'd expect. + # + # If you try and assign something in properties that is not supported, + # a warning message will be emitted. + # + # @param Hash the properties to set. + def style(properties) + clean_properties! properties + + properties.each do |key, value| + if key == :title && UIButton === self + setTitle(value, forState: UIControlStateNormal) + elsif respond_to?(:"#{key}=") + send(:"#{key}=", value) + elsif layer.respond_to?(:"#{key}=") + layer.send(:"#{key}=", value) + elsif key == :keyboardType + setKeyboardType(value) + else + $stderr.puts "Teacup WARN: Can't apply #{key} to #{inspect}" + end + end + + #OUCH! Figure out why this is needed + if rand > 1 + setCornerRadius(1.0) + setFrame([[0,0],[0,0]]) + setTransform(nil) + setMasksToBounds(0) + setShadowOffset(0) + end + end + + # merge definitions for 'frame' into one. + # + # To support 'extends' more nicely it's convenient to split left, top, width + # and height out of frame. Unfortunately that means we have to write ugly + # code like this to reassemble them into what the user actually meant. + # + # WARNING: this method *mutates* its parameter. + # + # @param Hash + # @return Hash + def clean_properties!(properties) + return unless [:frame, :left, :top, :width, :height].any?(&properties.method(:key?)) + + frame = properties.delete(:frame) || self.frame + + frame[0][0] = properties.delete(:left) || frame[0][0] + frame[0][1] = properties.delete(:top) || frame[0][1] + frame[1][0] = properties.delete(:width) || frame[1][0] + frame[1][1] = properties.delete(:height) || frame[1][1] + + properties[:frame] = frame + properties + end + end +end diff --git a/lib/z_core_extensions/ui_view.rb b/lib/z_core_extensions/ui_view.rb new file mode 100644 index 0000000..059a21a --- /dev/null +++ b/lib/z_core_extensions/ui_view.rb @@ -0,0 +1,4 @@ +class UIView + include Teacup::Layout + include Teacup::View +end diff --git a/lib/z_core_extensions/ui_view_controller.rb b/lib/z_core_extensions/ui_view_controller.rb new file mode 100644 index 0000000..dd08b8c --- /dev/null +++ b/lib/z_core_extensions/ui_view_controller.rb @@ -0,0 +1,62 @@ +class UIViewController + include Teacup::Layout + + class << self + # Define the layout of a controller's view. + # + # This function is analogous to Teacup::Layout#layout, though it is + # designed so you can create an entire layout in a declarative manner in + # your controller. + # + # The hope is that his declarativeness will allow us to automatically + # deal with common iOS programming tasks (like releasing views when + # low-memory conditions occur) for you. This is still not implemented + # though. + # + # @param name The stylename for your controller's view. + # + # @param properties Any extra styles that you want to apply. + # + # @param &block The block in which you should define your layout. + # It will be instance_exec'd in the context of a + # controller instance. + # + # @example + # MyViewController < UIViewController + # layout :my_view do + # subview UILabel, xjad: "Test" + # subview UITextField, { + # frame: [[200, 200], [100, 100]] + # delegate: self + # } + # subview UIView, :shiny_thing) { + # subview UIView, :centre_of_shiny_thing + # } + # end + # end + # + def layout(stylename, properties={}, &block) + @layout_definition = [stylename, properties, block] + end + + # Retreive the layout defined by {layout} + def layout_definition + @layout_definition + end + end + + # Instantiate the layout from the class, and then call layoutDidLoad. + # + # If you want to use Teacup in your controller, please hook into layoutDidLoad, + # not viewDidLoad. + def viewDidLoad + if self.class.layout_definition + name, properties, block = self.class.layout_definition + layout(view, name, properties, &block) + end + + layoutDidLoad + end + + def layoutDidLoad; true; end +end