Sharing UI Logic Across iOS and Android with Appcelerator Alloy MVC
Appcelerator Alloy MVC shares UI logic across iOS and Android while keeping platform-specific chrome in separate view files. This blog walks through a contact list example with shared models, declarative XML views, TSS styling, and platform overrides — plus debugging trade-offs.
23 Mar 2026, 16:22 UTC

The duplication problem in cross-platform mobile
Building the same feature twice — once in Swift or Objective-C for iOS, again in Kotlin or Java for Android — is the tax every mobile team pays when they choose native development. Appcelerator Titanium promises a single JavaScript codebase that compiles to native apps, but the classic "all-JS" approach often turns into a tangled mess of platform checks (if (Ti.Platform.osname === 'ios')) scattered through view logic. Alloy, Titanium's MVC framework, attacks this by enforcing a structure where UI markup, styling, and controller logic live in separate files, and where platform-specific overrides live in dedicated folders instead of conditional branches.
How Alloy separates concerns
An Alloy project organizes code under app/ with three primary directories:
- controllers/ — JavaScript files that handle events, data binding, and navigation. Each controller corresponds to a view.
- views/ — XML files that declare the UI hierarchy using Titanium UI components (
<Window>,<TableView>,<Label>, etc.). No logic lives here. - styles/ — TSS (Titanium Style Sheets) files that map CSS-like selectors to component properties. Think of it as CSS for native views.
Models live in app/models/ and provide Backbone-style data objects with events. When a model attribute changes, any view bound to that attribute updates automatically — no manual setText() calls required.
Platform-specific overrides without forking
The key to sharing logic while respecting platform conventions is the app/ios/ and app/android/ folder hierarchy. At build time, Alloy merges these platform folders on top of the base app/ tree. A view defined in app/views/index.xml applies to both platforms; place a modified version in app/ios/views/index.xml and only the iOS build sees it. The same rule applies to controllers, styles, and even assets.
This means you can keep a single controller for a login screen, share the same model validation logic, but give iOS a native navigation bar (<NavigationWindow>) while Android uses a toolbar — each in its own view file, no if/else in the controller.
Worked example: a shared contact list with platform styling
Suppose you need a contact list that shows avatar, name, and phone. The data comes from a REST endpoint and is stored in an Alloy model collection.
1. Define the model
// app/models/contact.js
exports.definition = {
config: {
columns: { id: 'string', name: 'string', phone: 'string', avatarUrl: 'string' },
adapter: { type: 'restapi', collection_name: 'contacts' }
},
extendModel: function(Model) {
_.extend(Model.prototype, {
validate: function(attrs) {
if (!attrs.name) return 'Name is required';
}
});
return Model;
}
};
2. Create the shared view
<!-- app/views/contacts.xml -->
<Alloy>
<Collection src="contact" />
<Window class="container">
<TableView dataCollection="$.contacts">
<TableViewRow class="contactRow">
<ImageView bindId="avatar" class="avatar" />
<View class="details">
<Label bindId="name" class="name" />
<Label bindId="phone" class="phone" />
</View>
</TableViewRow>
</TableView>
</Window>
</Alloy>
The dataCollection attribute binds the TableView to the controller's contacts collection. bindId maps model attributes to view elements — when a model's name changes, the corresponding Label updates automatically.
3. Shared base styles
/* app/styles/contacts.tss */
".container": { backgroundColor: "#fff" },
".contactRow": { height: 72, layout: "horizontal" },
".avatar": { width: 56, height: 56, borderRadius: 28, left: 12 },
".details": { layout: "vertical", left: 16, right: 12, center: { y: "50%" } },
".name": { font: { fontSize: 17, fontWeight: "bold" }, color: "#111" },
".phone": { font: { fontSize: 14 }, color: "#666" }
4. Platform-specific tweaks
iOS uses a navigation bar with a title; Android uses a Toolbar with a hamburger menu. Instead of branching in the controller, create two view files:
<!-- app/ios/views/contacts.xml -->
<Alloy>
<NavigationWindow>
<Window class="container" title="Contacts">
<Require src="contacts" type="view" />
</Window>
</NavigationWindow>
</Alloy>
<!-- app/android/views/contacts.xml -->
<Alloy>
<Window class="container">
<Toolbar id="toolbar" title="Contacts" />
<Require src="contacts" type="view" />
</Window>
</Alloy>
The <Require> tag pulls in the shared app/views/contacts.xml (the TableView definition) so the list markup exists in exactly one place.
5. Controller stays platform-agnostic
// app/controllers/contacts.js
var contacts = Alloy.Collections.contact;
contacts.fetch();
$.contacts = contacts; // exposes collection to view binding
function onRowClick(e) {
var model = contacts.at(e.index);
Alloy.createController('contactDetail', { contact: model }).getView().open();
}
$.tableView.addEventListener('click', onRowClick);
No platform checks. The controller fetches the collection, binds it to the view, and handles navigation. The platform-specific windows (NavigationWindow vs. Toolbar) are defined in the view layer where they belong.
Trade-offs and limitations
- Debugging generated code: Alloy compiles XML/TSS into classic Titanium JavaScript at build time. Stack traces often point to files in
Resources/(the generated output) rather than your source. Source maps help, but you'll still spend time correlating generated lines to your Alloy source. Runti build -p ios -b --log-level tracefrom the project root (requires Titanium CLI and Xcode command-line tools) to see the full compilation output and locate the generated files. - Learning curve for XML/TSS: Developers used to imperative UI construction (
var label = Ti.UI.createLabel({...})) need to think declaratively. TSS selectors (class, ID, element) behave like CSS but target native properties —width: '50%'works, butflex: 1does not. - Not all Titanium APIs have Alloy equivalents: Some low-level modules (e.g., custom native modules, certain Android intents) still require classic JS in the controller. You can always
require()them, but the clean separation gets muddy.
Verify it works before committing
- Create a test project:
ti create --type=alloy --name=ContactTest(run in a terminal with Titanium CLI installed; requires Appcelerator account and valid SDK). - Copy the model, view, style, and controller files above into the corresponding
app/directories. - Add platform view overrides under
app/ios/views/andapp/android/views/. - Build for iOS simulator:
ti build -p ios -b -T simulator(macOS only, Xcode required). For Android:ti build -p android -b -T emulator(Android SDK and emulator image required). - Launch the app. Scroll the list, rotate the device, pull to refresh if you add a
RefreshControl. Confirm the same JavaScript controller drives both platforms. - Modify a contact's name in the model (e.g., via a test script or the detail screen) and verify the list row updates without a manual refresh — this proves data binding works.
If the build fails with XML parse errors, check that all tags are closed and <Require> paths match case-sensitive filenames. If the list renders but binding doesn't update, ensure the controller assigns the collection to $.contacts (the $ namespace is the view proxy).
Next step: migrate one screen
Don't rewrite the whole app. Pick a screen that exists on both platforms with minor visual differences — a settings page, a read-only list, a profile view. Move its markup to a shared XML view, extract common styles to a base TSS file, and create platform-specific view wrappers only where the chrome differs. Keep the controller unchanged. Once that screen builds and runs on both targets, you have a repeatable pattern for the rest of the codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.