Skip to content
This repository has been archived by the owner on Apr 25, 2024. It is now read-only.

User interface

Alexander Miseler edited this page Aug 29, 2016 · 21 revisions

Introduction

Android SDK comes with a declarative way to define your UI in XML. This approach is much simpler and more convenient than writing tons of Java boilerplate (like in Swing). Although having your UI in entirely different language has its shortcomings. Here is the brief list of them:

  • It’s static. Since we aim at dynamic development (the way Clojure allows) we want the ability to dynamically modify every part of our application, and native UI tools stand in the way of that.
  • XML. Enough said.
  • Different language. Like if the previous point wasn’t bad enough, declaring user interface in XML is even more disadvantageous because it’s a separate language and a separate compilation stage. You can’t manipulate things written in XML from the Java side, and the possibilities for extending the existing behavior of XML config transformation are limited.

On the other hand, Neko provides it’s own solution to describe user interface declaratively. Among other advantages it brings similar to Android’s native approach it is also:

  • Dynamic. You can recompile your UI definition at any time at the REPL.
  • You write Clojure. Neko’s UI element descriptions are just Clojure’s native data structures (vectors and maps). It means that you can use Clojure’s ordinary data-processing facilities to manipulate these definitions in any way (for example, generate repetitive UI elements or reuse some common idioms).
  • Highly extensible. Neko provides you all tools to write your own elements and attribute transformers.

    This allows neko.ui to be considered a viable replacement for the original UI framework when writing applications in Clojure. However you can fall back to defining XML layouts any time it feels appropriate (for example, if you want to use WYSIWYG GUI tools like the one Eclipse provides).

Using neko.ui

Basics

The main building block of neko.ui is a UI tree. It is a vector that has the following syntax:

[element-type attributes-map & inside-elements]

inside-elements are the same vectors, hence the whole thing is a tree. You can pass such UI tree to set-content-view!, use in adapters, or on the lowest level pass it to neko.ui/make-ui which will return a View object constructed from this UI tree.

For example:

;; `this` is our Activity object
(set-content-view! this [:linear-layout {:orientation :vertical}
                         [:edit-text {:id ::name-et
                                      :hint "Put your name here"}]
                         [:button {:text "Submit"
                                   :on-click (fn [_]
                                               (let [name-et (find-view this ::name-et)]
                                                 (submit-name (.getText name-et))))}]])

Let’s examine in detail every part of this example.

Element type is a keyword that represents Android UI view. Every element type has its respective class name, attributes it can contain, default attribute values and other parameters. You can see a list of all available elements by calling (neko.doc/describe). Also you can inspect each element separately by providing an element type to describe.

The second value in UI tree is an attribute map. It consists of pairs where key is a keyword representing an attribute, and value is the value for this attribute. Many elements have their default attribute values for cases if you don’t provide them, for instance, a button’s default text is “Default button”. If you don’t want to provide any attributes to an element, put an empty map there anyway.

After that comes an optional number of elements that should go inside the current element. This only makes sense for elements that extend ViewGroup class, so they contain other elements. Every sub-element definition is a vector itself and follows the same rules. You can see that vectors for EditText and Button have only two values each inside, since they can’t serve as containers to other elements.

Injecting already created elements into the layout

make-ui expects every element in the UI tree to be a sequence to be treated as UI definition, or any other object (or nil, then it will simply be ignored).

(make-ui context (concat [:linear-layout {}]
                         (map (fn [i]
                                [:button {:text (str i)}])
                              (range 10))))

In this example UI tree will first be constructed from 10 buttons that are stuffed inside :linear-layout container, and then make-ui will generate this layout.

You can also insert an arbitrary, already created View inside your UI tree.

(let [cancel-button (Button. context)]
  (.setText cancel-button "Cancel")
  (make-ui context [:linear-layout {}
                    [:button {:text "OK"}]
                    cancel-button]))

Attributes

Many properties for Android UI elements follow a convention of having a separate dedicated setter. This allows us to omit explicit description of most attributes for every element. By default, attribute definition is transformed to code in the following way: :attribute-key attribute-value becomes (.setAttributeKey obj attribute-value). As you can see, attribute key is transformed into a setter by removing dashes, turning the string into CamelCase and putting “set” at the beginning.

If value is also a Clojure keyword, it is perceived as a static field of the element class and transformed as well; thus :attribute-value becomes ElementClassName/ATTRIBUTE_VALUE. In this case the rule is somewhat different: all letters are uppercased and dashes are replaced with underscores.

By using this feature we didn’t even need to have an explicit :orientation attribute handler for linear layout in the previous example. The attribute pair :orientation :vertical was turned into (.setOrientation LinearLayout/VERTICAL), which is exactly what we need.

Special values

Sometimes it is useful to define custom keyword values for attributes that don’t exactly match a static field. For example, ProgressDialog’s progress style attribute can have two values: STYLE_HORIZONTAL and STYLE_SPINNER. Of course, you can set it like this: :progress-style :style-spinner. On the other hand an element can contain a mapping of special keywords to values:

;; somewhere in :progress-dialog definition
:values {:horizontal ProgressDialog/STYLE_HORIZONTAL
         :spinner    ProgressDialog/STYLE_SPINNER}

This allows you to specify the attribute like :progress-style :spinner instead.

You can view the map of special values for element by calling (neko.doc/describe element-keyword).

Custom context and constructor

Most elements have a constructor that takes one argument - context. This constructor is used by default in neko.ui, and application context is passed to it as an argument.

However some elements doesn’t have this type of constructor, or require you to provide some values that you can’t later set with a setter. Passing additional arguments to a constructor can be done via :constructor-args attribute which takes a list of arguments. Note that you have to specify only additional arguments as the first argument (a context) is provided automatically.

(make-ui context [:foo {:constructor-args [1 2]}])

You also might end up in a situation where you have to construct an element in a completely different way (for example, by using proxy or reify). For this case you can use :custom-constructor attribute which value should be a function that takes a context and any other set of arguments. This way you can combine :custom-constructor and :constructor-args to create any object you want.

(make-ui [:image-view {:custom-constructor
                       (fn [ctx foo bar]
                         (proxy [android.widget.ImageView] [ctx]
                           (onDraw [^Canvas canvas]
                             ...)
                           (onTouchEvent [^MotionEvent e]
                             ...)))
                       :constructor-args ["foo" 42]}])

Traits

Many attributes have their setter counterparts but some of them don’t. Or there are some attributes that you want process simultaneously. You might even want to introduce some special behavior via attributes that isn’t possible with setters.

To be able to do such things neko.ui has a concept of traits. A trait is a special function that accepts element’s attributes map, takes out the attributes it should work on and generates Clojure code from them. Each element has its own list of traits, and also it inherits its parent traits.

describe when called on the element keyword prints all traits for this element with the detailed description. You can also call describe on the trait name itself to see the documentation for it.

List of most useful traits

Every trait has a name that is a Clojure keyword. Usually a trait seeks for attribute with the same name as trait’s name. This is considered a default behavior unless stated otherwise.

If you see that some trait is used by :view, for example, it means, that every element that inherits from :view also gets this trait.

  • :layout-params, used by :view-group.

    Operates on the vast number of attributes: :layout-height, :layout-width by default, :layout-weight and :layout-gravity for LinearLayout, all types of relative descriptions for RelativeLayout, :layout-margin-top/left/right/bottom for both Linear and RelativeLayout. Creates an appropriate LayoutParams object based on these attributes and the type of the container.

    Options :layout-height and :layout-width can have two special values: :fill and :wrap which correspond to FILL_PARENT and WRAP_CONTENT respectively. If not provided :wrap is used by default.

    Example:

[:linear-layout {}
 [:button {:layout-width :fill
           :layout-height :wrap
           :layout-weight 1}]]
  • :id, used by :view.

    First of all, this trait sets ID of the widget to the value of :id attribute (by calling .setId method. But its primary goal lies in cooperation with neko.find-view/find-view function. Together they allow to obtain references to child views from the parent view.

    You can see an example of how it is used in neko.find-view description.

(let [contact-item (make-ui context [:linear-layout {:id-holder true}
                                     [:linear-layout {:orientation :vertical}
                                      [:text-view {:id ::name}]
                                      [:text-view {:id ::email}]]
                                     [:button {:id ::submit}]])]
  (find-view contact-item ::email) => returns TextView object)
  • various listeners

    Most functions in neko.listeners.* have respective traits. For example, :on-click attribute takes a function and wraps it into on-click-call automatically.

[:button {:on-click (fn [_] (toast this "Clicked!"))}]

Extending neko.ui

Neko.ui initially provides a small set of elements a traits. Its main goal is to let user create new UI entities and behaviors whenever he needs them, and do it easily.

Defining new elements

You can define new elements in any part of your program (but obviously prior to using them) with neko.ui.mapping/defelement function. It takes an element’s name (which should be a keyword) and optional key-value arguments. Here’s the list of them:

  • :classname - a class of a real Android UI element. This option is obligatory for every element that you plan to use in UI tree directly (so you can omit it for abstract elements that you plan only to inherit from).
  • :inherits - parent element’s name that you want to inherit from. Traits, special values and default attributes are inherited. It is suggested that you inherit your elements at least from :view to gain the most common traits.
  • :traits - a list of traits to be supported by this element. You don’t have to specify traits that are already inherited from the parent element.
  • :values - a map of special value keywords to actual values.
  • :attributes - a map of default attribute values that will be used if attribute is not provided.

Example:

(defelement  :image-view
  :classname android.widget.ImageView
  :inherits  :view
  :traits    [:specific-image-trait :another-trait]
  :values    {:fit-xy ImageView$ScaleType/FIT_XY
              :matrix ImageView$ScaleType/MATRIX}
  :attributes {:image-resource android.R$drawable/sym_def_app_icon})

Here we define an element called :image-view which represents an original ImageView. We inherit it from :view which automatically gives our new elements some useful traits. Then we provide additional traits via :traits option. :values allows to specify convenient keyword aliases for values hidden behind ScaleType class. Finally, using :attributes we define the default image for the element which will be used if user doesn’t provide this attribute.

Creating new traits

First, let us recall what a trait is. Trait is a special function that takes some attribute(s) out of the attribute map and performs specific actions based on their values. Since most of the attributes are covered by the default transformation into a setter, traits are only necessary for more complex cases.

There is a special macro called neko.ui.traits/deftrait for creating traits. Here is how its arguments look like:

[trait-name docstring? param-map? args-vector & body]

Let’s describe them one by one:

  • trait-name is a keyword that will represent this trait. This name is to be added to UI elements’ trait list.
  • docstring (optional argument) is a way to add some info about the trait, and can be later accessible via neko.doc/describe.
  • param-map (optional argument) is a function that takes a map with certain trait parameters. The following parameters are supported:
    • :attributes — a vector of keywords that denote attributes which trait is applied to. By default, trait is looking for the attribute with the same name as itself. Hence, the trait named :text will be only applied if element’s attribute map contains :text attribute. But if a trait operates on more than one attribute, this parameter allows to specify it. Also this parameter is used to determine which attributes should be removed from the map after trait finishes its job.
    • :applies? — if you need even more complex method to determine whether trait should be applied to the given widget and attributes, you can put it into this parameter. This should be an expression that returns a boolean value. The expression can use all variables from args-vector.
  • args-vector is a binding vector for the trait function. Remember that a trait functions take a widget, an attributes map and an options map, but it’s up to you how to destructure it.
  • body is the main part of the trait. It operates on passed UI widget based on attributes’ values.

    Usually trait’s body doesn’t have to return anything. But occasionally you might want to change what happens to the attribute map after the trait finishes (by default, the used attributes are dissoc’ed from it). Same for options map. To provide your custom update functions for these two maps, your trait body should return a map with :attributes-fn and/or :options-fn values.

    For new widgets to be able to use your trait you should list it in :traits as desribed here. If you want to enable new trait for existing widget types, you should call the following code before using them:

(neko.ui.mapping/add-trait! :trait-kw :widget-kw)

Options

So far I didn’t tell you what are these “options” and how do they differ from attributes. Options map is an internal way to pass values between traits, from higher-level elements to their subelements.

If this still doesn’t make sense, look how it works. A trait of some container element (the one that contains other elements, like a LinearLayout) can put some values on the options map. These values will become visible for all traits that are called on the inside elements of the container. These internal traits can use options values to implement custom behavior, and modify the options map themselves for their own subelements.

:id-holder and :id traits are an example of options usage. If :id-holder attribute is true for some container, the respective trait puts this container’s object on the options map. Later the :id trait (which is called on elements with this attribute) will take the container from options map and the child element to the container’s tag.

By default (if :options-fn is not specified in the return value of codegen-fn) options map is not changed as a result of trait’s activity. :options-fn if provided takes options map as an argument and can put new values to it or remove existing ones.

Examples

In this example we create a trait named :foo that generates a call to .setFooBar with the value of :foo attribute. The attribute will be automatically removed from the map in the end.

(deftrait :foo
  "You may put docs here"
  [wdg attributes options]
  (.setFooBar wdg (:foo attributes)))

Why do we need to remove anything from the attribute map? If you remember, after all traits are applied, the default attribute transformer kicks in and turns all remaining attributes into simple setters. Since a trait already processed its attribute, we don’t want the default transformer to do this again (besides, incorrectly).

Although there might be cases where you need to clean attribute map in a more complex way (e.g. you processed several attributes and want to remove them all). For this you can specify a list of attributes in the parameters map. You can also modify the resulting options map by returning a map like following:

(deftrait :foobar
  ":foobar trait consumes :foo and :bar attributes, and only if
both of them are present"
  {:attributes [:foo :bar]
   :applies? (every #{:foo :bar} attrs)} ;; Trait will only apply if
                                         ;; both attributes are present
  [wdg attrs options]
  (.setFooBar wdg (:foo attrs) (:bar attrs))
  {:options-fn #(assoc % :cached-foo foo)}) ;; Put value of foo onto
                                            ;; the options map