This repo is a minimal port of the classic evancz/elm-todomvc application, with the additional infrastructure necessary to run as a native, cross-platform mobile application powered by Ionic Capacitor.
All of the application code is in Elm! To get started quickly, simply fork this repo, then scroll on down to the Caveats section for a brief overview of the biggest difference between Elm applications on the web, and Elm applications in Ionic Capacitor.
But wait - there's more! This repo serves as a tutorial. I've tried to build the different layers of this application as a set of pull requests that are all linked in this document. The comments on the pull request show which changes were made, when, and why - and you can read an overview for each major changeset here in this readme, as well.
If you have questions, feel free to shoot me a DM in Elm Slack, or ping me on Twitter - @lambdapriest.
For this demo application, I've started with an existing app; but you can do whatever you like. There are some limitations, though; you'll have to create a Browser.element
or Browser.document
application. (For more notes on this, see Caveats - URL Routing / Navigation.)
This repo starts with a simple Elm app that only contains what's absolutely necessary - an elm.json
file, a src/Main.elm
file, and an index.html
file to host the built JavaScript. In fact, if you check out the project and navigate to the Git hash that was merged in during the Step 1 PR, you can build the app just by navigating to the root of the project, and running:
elm make src/Main.elm --output=elm.js
Then opening index.html
in a web browser.
We'll need npm in order to install the dependencies we need to get going with Capacitor; and if we get set up with a bundler now, we can have nice things like build-on-save while we're working.
In this step, we installed the following npm packages:
vite
vite-plugin-elm
Then, we refactored the little bit of JavaScript in index.html
that hosts our elm.js
file into a module called index.mjs
, which will make it easier to integrate with our bundler, Vite, as well as with some of the other Capacitor packages we'll need.
We added vite-plugin-elm
, because it lets you import your src/Main.elm
file directly into your index.mjs
file, which Vite automatically picks up on build. We configured the vite.config.js
file to run vite-plugin-elm
during our build step.
Finally, we added a simple npm script, start
. It sets the ENV
environment variable to DEV
and runs vite --host
. Our vite.config.js
uses the ENV
value to set a JSON object that it passes to the elmPlugin
function from vite-plugin-elm
, which sets the --debug
and --optimize
flags from elm make
. (We want to se the debugger when we're in our development environment, but we don't want to see it when we do a release build.)
Note: The only files that were hand-edited in this pull request are
package.json
,Brewfile
, and.gitignore
. The rest of the assets on this PR are auto-generated by the commands that we will run in this section, which makes the diff not very nice to read. Still, it's instructive to see what Capacitor is doing for us, so don't be afraid to poke around a little!
Now that we have npm and a bundler, we can add Capacitor to our project. This is remarkably simple - the docs are here, but I'll summarize:
npm install @capacitor/core
npm install -D @capacitor/cli
npx cap init
, and follow the prompts. This createscapacitor.config.json
.
npm install @capacitor/android @capacitor/ios
npx cap add android
npx cap add ios
This will create Android and / or iOS projects (depending on what platform plugins you have installed, and which of these commands you run), at android/
and ios/
respectively.
The iOS project has a dependency on CocoaPods, so you'll need to install that. My preferred method is to install it via Homebrew. I've added a Brewfile
to this project; if you have Homebrew installed already, simply run
brew bundle
from the root directory, before you run npx cap add ios
.
Here's where we diverge slightly from the docs.
If you run npx cap sync
, Capacitor will copy whatever's in the folder specified in the webDir
value of capacitor.config.json
- for us, that's "dist"
- into those projects.
Right now, our dist
folder contains whatever Vite bundled the last time it ran - if it even ran at all (i.e., if your project is building in a CI pipeline, or if a friend is helping you and they just pulled this repo down on their computer). When we finish our app and want to release it to the App Store, we'll need to make sure that our built output doesn't include the Elm debugger; so it's helpful to wrap the call to npx cap sync
in another npm script so that we can specify the environment that our Elm app is building in.
So to enable this, we've added two more npm scripts:
sync-dev
: Runs the Vite build withENV=DEV
, then runsnpx cap sync
sync
: Same assync-dev
, but withoutENV=DEV
so that our built output is optimized and has the Elm debugger removed
You can run npx cap open android
to open your Android project in Android Studio; you can run npx cap open ios
to open your iOS project in Xcode.
Because I hate remembering commands, I've added these as npm scripts - open-android
and open-ios
, respectively. Adding them to my package.json
ensures that all of the commands that I need to run in order to work on this application are written down somewhere as part of my project.
Note: Capacitor doesn't actually build binaries of your mobile apps for their respective platforms; it just maintains a project for each platform, and copies your web artifacts over when you run
npx cap sync
. If you're using Windows or Linux, you'll still have to have a Mac to build and run an iOS project; fortunately, cloud CI solutions exist!
Now that your project is open, you can build and run it in the simulator of your choice! You'll notice that the app isn't drawing correctly in the viewport - depending on the simulator you run with, perhaps a "notch" is covering part of the content; perhaps the content is sized poorly or off-center; and when you tap a text input to enter a "todo" item, the display may zoom in on the field while the keyboard opens.
All of these are very un-app-y behaviors; the bulk of the work that we have left to do concerns making it so that our web application behaves like a native mobile application.
If you've been making changes to your project and you want to see how it looks in an emulator on your device - run npm sync-dev
or npm sync
, then rebuild and restart the emulator.
On one hand, the android/
and ios/
folders are a sort of build artifact, in that they are auto-generated by npx cap sync
; but on the other hand, you can edit some of the config files in those directories, and those changes will be persisted across repeated runs of npx cap sync
- so in practice, for me, it's much easier to have them included in source control.
At this point, we have our Elm application running as a native mobile app, but it doesn't feel very "app"-y.
To fix this, we're going to wire in some of Capacitor's platform hooks, so that we can give our Elm application more information about its runtime environment - as well as make some small layout tweaks in order to get it to behave.
In order to access those hooks, we'll need to:
The two values we care most about are:
- Is this app running on a device, or an an emulator (i.e., is it virtual or not)?
- Believe it or not, we really care about this - if, for instance, you want to show ads in your app? You had better make sure that your app doesn't show ads during testing, because Google will flag your AdMob account for "generating fraudulent ad traffic" when it runs your app in hundreds of testing VMs to make sure that it boots on every possible version of Android, ever; and that is not fun (ask me how I know).
- What's the offset for the "safe area" at the top of the screen? How much padding do we need so that we don't accidentally draw behind a notch or camera hole?
You'll have to update index.mjs
to call the functions from @capacitor/device
and capacitor-plugin-safe-area
in order to pull back device info and send it in as part of your flags value; and update src/Main.elm
to accept the updated flags value, as well as operate on the new values you're sending in.
Finally, we need to add one line of code to index.html
to set our Capacitor app's browser to constrain the content to the width of the device, and to disable zooming.
(The pull request dives in a little deeper here with the code changes; it's recommended reading!)
In order to release your application on the App Store and Google Play Store, you're going to need app icons and a splashscreen. Since we're letting Capacitor control virtually all of the particularities about building our apps, we'll also need to use Capacitor tooling to generate our app icons and splashscreen and build them into our Xcode and Android Studio projects.
Fortunately, cordova-res
makes this easy. Just run:
npm install cordova-res
docs
I've added an npm script, icons-splashscreen
, to call it with the appropriate arguments and generate the resources, and I've added the call to npm run icons-splashscreen
in to the sync
and sync-dev
scripts, for convenience's sake.
Just kidding! Once you've got your app "done", you'll have to run the gauntlet to get your app uploaded, submitted, approved, and so on and so forth.
Fortunately, once you can build your app with Xcode and Android Studio - the rest of the process is pretty straightforward, and just like releasing any other app. There's more than enough written on the Internet about that, so I'll leave this part as an exercise for the reader.
Of course, there are other Capacitor plugins that you can use to access native device functionality - and since Capacitor is based on Apache Cordova, there are many other plugins that you may be able to use.
Congrats! If you've made it this far and you still have questions - again, you can find me in Elm Slack as @jmpavlick
, or on Twitter @lambdapriest.
If you've found any of this to be in error, PRs are always welcome.
Due to limitations with Elm's URL routing and due to constraints on web views in iOS, you can't give your Elm application full control of the DOM, so you can't create your app as a Browser.application
. The biggest limitation here is that you can't use elm/url
routes or browser navigation - but that's really not a big deal!
URL routing only really matters when the URL bar should be available to the user as a means of input. Since the URL bar in the web viewer is hidden for an Ionic Capacitor app, you don't need to worry about it at all! You can simply store a value on your Model
that tracks your user's location in the app, and you can retrieve that value from local storage when your app loads, through your flags.
For this reason, I strongly recommend using a Browser.element
- so that you can use flags to send input parameters to your application, and so that you can use ports to set and retrieve values from the web viewer's local storage (which is persisted on the mobile filesystem). You can also use a Browser.document
, but since your users will never see the <title>
of the page, there's not really any reason to.