Bartebuss – the beginning

Rutedata m/sanntid

Rutedata m/sanntid

Tidlig i sommer snublet jeg over et prosjekt kalt BusBuddy, hvor noen studenter ved IDI hadde laget et kult busskart med sanntidsdata fra AtB. Men de hadde ikke bare stoppet der, de hadde også lagd et API for å dele rådataene. Siden AtB offisielt enda ikke hadde gjort dette selv, var det nesten som en liten «undergrunnsbevegelse» av bussnerder som begynte å mekke smarte løsninger, meg selv inkludert.

Jeg hadde lyst å lage en mobilvennlig webapp, heller enn noe som bare funket på Android eller iOS (iPhone etc). Trafikantens presentasjon av ruteinfo for buss/trikk i Oslo var utgangspunktet, og jeg satte i gang å leke. Det skulle bli en HTML5- og JavaScript-drevet «dings» som ikke krevde pageloads, som var rask, så bra ut og utnyttet nye HTML5-muligheter, som f.eks. å bufre data og hente brukerens posisjon.

Resultatet av det jeg beskriver under finnes på bartebuss.no, samt som Android-appen Bartebuss på Android Market.

Dette kommer til å bli en ekstremt nerdete, usammenhengende og lang bloggpost. Du er herved advart!

Rammeverket

En webapp bør se ut og oppføre seg litt annerledes enn en vanlig webside, blant annet ville jeg ikke ha mulighet til zooming siden appen likevel skulle bruke standard fontstørrelse og være full skjermbredde, og jeg ville kunne angi pene, store ikoner og splash-screens. iPhone kan kjøre sider i webappmodus (uten adresse- og verktøylinjer), det ville jeg også dra nytte av.

Til slikt finnes det mange fine meta-elementer man kan bruke.

<!-- Full bredde, ingen zooming -->
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<!-- Tillat å kjøre i fullskjermmodus i iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Ikoner og splashscreens for alle varianter av iOS (iPhone/iPad/iPhone retina) -->
<link rel="apple-touch-icon" href="/img/icon-57x57.png" />
<link rel="apple-touch-icon" href="/img/icon-72x72.png" sizes="72x72" />
<link rel="apple-touch-icon" href="/img/icon-114x114.png" sizes="114x114" />
<link rel="apple-touch-startup-image" href="/img/splash-748x1024.png" media="screen and (min-device-width: 481px) and (max-device-width: 1024px) and (orientation:landscape)" />
<link rel="apple-touch-startup-image" href="/img/splash-768x1004.png" media="screen and (min-device-width: 481px) and (max-device-width: 1024px) and (orientation:portrait)" />
<link rel="apple-touch-startup-image" href="/img/splash-320x460.png" media="screen and (max-device-width: 320px)" />

For Android må man jukse litt for å unngå konflikt med iOS-ikonene.

if(navigator.userAgent.match(/Android/i))
    $('head').append('<link rel="apple-touch-icon-precomposed" href="/img/icon-114x114.png" />');

Siden verktøylinjene i browseren forsvinner når man kjører i webappmodus, startet jeg med å bygge en verktøylinje med utgangspunkt i hvordan Seesmic og Facebook gjør det. All HTML for denne, pluss skjelett for alle undersidene, ble plassert i én HTML-fil, slik at lasting av undersider ble unødvendig. Målet var i tillegg å bruke minst mulig PHP i brukerens ende, slik at mest mulig kunne kjøres client-side.

HTML5 history

For å slippe pageloads, men samtidig ikke ødelegge tilbake-knappen tok jeg i bruk HTML5 history. Det fungerer slik at man dytter en URL og en sidetittel på en stack, sammen med et JSON-objekt som beskriver en tilstand (state). Man velger selv hvordan man ønsker å bygge state-objektet sitt, så lenge man greier å gjenskape den forrige siden basert på dataene det inneholder. Jeg valgte å ta vare på sidens ID, hvilken fane i appen det tilsvarte, og dobbeltlagre lenka.

var state = {
    'page': page.attr('id'),
    'tab': tab.attr('id'),
    'link': link
};
history.pushState(state, null, link);

For hvert pushState man gjør, får man et ekstra innslag i historikken å gå tilbake til. Samtidig endres URLen i adressefeltet, akkurat som om man hadde lastet en ny side. For å unngå å skape et logginslag ved første sidelasting (men samtidig gjøre det mulig å gå tilbake dit), bruker man replaceState ved første pageload. Litt logikk for å finne ut hva som er første er naturligvis også nødvendig.

Når brukeren trykker tilbake-knappen trigges en popstate-event, denne må man knytte en handler til.

$(window).bind('popstate', function(e){
    // Hent det gamle state-objektet fra eventen (eller originalEvent for jQuery)
    var previousState = e.originalEvent.state;
    // Gjør noe magisk, last riktig side, sett riktige variabler osv.
});

Handleren gjør det som er nødvendig for å gjenskape hvordan den forrige siden så ut.

Direktelenker til tilstand

Hvis man ønsker å la det være mulig å gå direkte til lenker/tilstander inni appen (direkte til en søkeside el.l.) må man sjekke sidens URL ved lasting, og gjøre de nødvendige tingene derfra. Jeg brukte JQuery URL Parser plugin, og trigget klikk/tap på lenkene/knappene som normalt fører brukeren til angitt tilstand/side.

var urlSegment = $.url().segment(1);
switch(urlSegment){
    case 'alle':
    case 'sok':
        $('#searchTab a').trigger('tap');
        break;
    case 'orakel':
        $('#oracleTab a').trigger('tap');
        break;
    case 'kart':
        $('#mapTab a').trigger('tap');
        break;
    ...
}

Det er en god grunn til at jeg ikke trigger en click-event, men heller den egendefinerte tap-eventen, mer om det under.

HTML5 localStorage

Resource inspector, ChromeÅ jobbe med ca 1400 holdeplasser for Trondheimsområdet betød at det ville være en fordel å mellomlagre alle på mobilen, heller enn å laste dem ned hver eneste gang. Jeg skrev derfor et script som lastet dem ned én gang, lagret dem i localStorage, og kun oppdaterte hvis lista var over en uke gammel – eller den fikk beskjed om å oppdatere seg (ved viktige endringer).

For å avsløre om brukerens enhet/browser støttet forskjellig «ny» funksjonalitet tok jeg i bruk Modernizr. Datoer tok jeg hånd om med date.js.

$.ajax({
    url: 'http://api.busbuddy.no/api/1.2/busstops?callback',
    dataType: 'jsonp',
    jsonpCallback: 'busbuddyResponse',
    data: {'apiKey': xxx},
    timeout: 5000,
    success: function(data){
        if(Modernizr.localstorage){
            localStorage.setItem(
               'busStops',
               JSON.stringify(data.busStops))
            localStorage.setItem(
               'lastUpdated',
               Date.today().setTimeToNow().toString(bartebuss.timeformat));
        }
        stops = data.busStops;
    },
    error: function(){...},
    complete: function(){...}
});

Tap delay

Selv gode webapps føles tregere enn native apps, og en av grunnene er at det i browseren er en forsinkelse på omkring 300 ms fra brukeren berører skjermen til en klikk-event sendes. Årsaken er at browseren venter på et evt. dobbelklikk, som brukes hvis man vil zoome inn på en bestemt del av siden.

Denne forsinkelsen får man ikke fjernet, men man kan velge å lytte til touchstart-eventen, heller enn click-eventen.

Det finnes forskjellige script som mener å løse problemet, men de fleste har mangler. Ulempen er at det i browsere på enheter uten touch-skjerm ikke fyres touchstart, bare click. Man trenger dermed en fallback i disse tilfellene. Man kan likevel ikke lytte til begge eventene på én gang, fordi Safari på iOS fyrer både touchstart og click (etter 300 ms), og man får da dobbelt opp. Først touchstart, og så click 300 ms senere. Hvis man rekker å endre til en ny side innen de 300 ms, havner click-eventen på den nye sida, heller enn på den opprinnelige knappen/lenka, og ting begynner å skje av seg selv. Veeeldig frustrerende å debugge.

Heldigvis løser  jQuery.tappable plugin problemet rimelig greit. Android 2.1 hadde problem med at klikkene døde med mindre man var veldig lett på fingeren, fordi den trodde fingeren hadde flyttet seg mellom touchstart og touchend. Jeg løste det ved å hacke inn en buffersone på noen få piksler rundt berøringsstedet. Jeg la også til min egen tap-event, som jeg kunne trigge for å simulere en berøring eller et klikk – avhengig av hvilken enhet brukeren hadde.

$('#backButton').tappable(function(){
    history.back();
});

Siste rest av weben

Markering av valgt elementFremdeles er det småting som avslører web vs. native app, f.eks. markeringen/outline browseren legger på områdene man berører/klikker, samt klipp-lime-funksjonen. Dette løser man enkelt i CSS.

/* Ingen klipp-lim */
-webkit-user-select: none;
/* Ingen popups på siden */
-webkit-touch-callout: none;
/* Ingen markering av "berørt" område */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
/* Ingen automatisk tekstskalering */
-webkit-text-size-adjust: 100%;

/* Hvis man er dristig kan man fjerne outline på lenker/knapper osv også */
input[type=submit],
button,
a.knapp {
    outline: none;
}

Det siste avslørende momentet overshoot på scrolling, altså at man kan trekke siden opp/ned og få en gummistrikkeffekt om der ikke er mer innhold å vise. Det kan skrus av ved å fikle med touchmove-eventen, men siden jeg ønsker å ha rulling på siden likevel (pga. lange lister) har jeg ikke tatt meg bryet med å ordne en «fast» side.

Installering

Maseboble (Cubiq-versjonen) På iPhone/iPad er det veldig enkelt å «installere» en webapp. Alt man trenger gjøre er å legge til et bokmerke på hjem-skjermen, og vips får man et pent ikon med runde kanter og gloss. (Gitt at man har fulgt oppskriften over). Når man starter webappen derfra forsvinner verktøylinjene og appen vises i fullskjerm-/webappmodus. På Android må man først lage et bokmerke, og deretter plassere bokmerket på hjem-skjermen. Litt mer omstendelig, men man oppnår også da et pent ikon. Splash og fullskjermmodus må man dog være foruten.

Hvis man ønsker/tør å plage brukerne en ekstra gang for å få dem til å «installere» (dvs. bokmerke) appen, kan man legge til en «maseboble» (annen variant) som spretter opp og ber brukeren om å legge til et bokmerke.

Jeg brukte Googles variant i starten, men bestemte meg for å fjerne den i tilfelle den var til bry og irritasjon.

Da skulle basiskunnskapen være på plass for å lage en god og rask webapp, som det vil kreve et trent øye for å skille fra en native app.