Filtrare una lista lato client con jQuery mobile

Durante la mia sessione sulla realizzazione di applicazioni web orientate al mobile, nel corso dell’evento WeWantWeb organizzato da DomusDotNet, ha suscitato particolare interesse la funzionalità di filtro testuale integrata in jQuery Mobile.

In quell’occasione non c’è stato il tempo sufficiente per approfondire le potenzialità della funzione, così ho deciso di tornare sull’argomento in questo post. Tutti sappiamo che jQuery Mobile è in grado di costruire elenchi filtrabili con il semplice utilizzo di un attributo. Quello che vogliamo fare oggi è invece consentire agli utenti della nostra applicazione di effettuare ricerche con molteplici chiavi, simulando il funzionamento della ricerca di Outlook 2010 che consente di filtrare i messaggi con la notazione etichetta:(valore ricercato) come nel caso del mittente o dell’oggetto del messaggio:

Filtro di Outlook 2010

Partiamo dalle basi e creiamo un nuovo progetto ASP.NET MVC 4 vuoto (in realtà anche in template Empty aggiunge qualcosa al progetto iniziale, ma nulla che ci dia particolarmente fastidio). In realtà tutta la logica interessante che vedremo si trova lato client e potremmo quindi utilizzare un progetto Web Forms o anche partire da una semplice pagina HTML.

Creare un nuovo progetto Empty

Una volta creato il progetto, aggiungiamo un riferimento al package jQuery.mobile tramite NuGet (per maggiori informazioni sull’utilizzo di NuGet potete leggere questo articolo sul sito di DomusDotNet).

Aggiungere jQuery.mobile al progetto con NuGet

Per cominciare modifichiamo la pagina di layout base _Layout.cshtml - che come sappiamo nel progetti MVC svolge un compito simile a quello delle Master Page di Web Forms - aggiungendo anche un link diretto ai file js utilizzati (in luogo dell'importazione che sfrutta il Bundling e Minification di MVC 4 Beta) per poter eventualmente consultare i file lato-client con più comodità:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>@ViewBag.Title</title>
    <link href="~/Content/css" rel="stylesheet" type="text/css" />
    <link href="~/Content/themes/base/css" rel="stylesheet" type="text/css" />
    <script src="../../Scripts/jquery-1.6.4.js" type="text/javascript"></script>
    <script src="../../Scripts/jquery.mobile-1.1.0.js" type="text/javascript"></script>
</head>
<body>
    <div data-role="page" data-theme="d">
        <header>
            <div data-role="header">
                @if (IsSectionDefined("Header"))
                {
                    @RenderSection("Header")
                }
                else
                {
                    <h1>@ViewBag.Title</h1>
                }
            </div>
        </header>
        <div data-role="content">
            @RenderBody()
        </div>
        <footer>
            <div data-role="footer">
                @if (IsSectionDefined("Footer"))
                {            
                    @RenderSection("Footer")            
                }
                else
                {
                    <h4>
                        &copy; @(DateTime.Now.Year)
                    </h4>
                }
            </div>
        </footer>
    </div>
</body>
</html>

Spostiamoci adesso sul back-end e creiamo un semplicissimo modello che useremo come base per i test. Per semplificare al massimo le operazioni lato-server (visto che vogliamo concentrare la nostra attenzione lato-client) aggiungiamo al modello una proprietà statica che restituisca un elenco fisso di elementi invece di usare Entity Framework:

namespace Filtering.Models
{
    public class ContactModel
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public string HomeNumber { get; set; }
        public string MobileNumber { get; set; }

        public static List<ContactModel> Contacts
        {
            get
            {
                return new List<ContactModel>
                {
                    new ContactModel { Name = "Bianca Celli", Description = "Vincitrice torneo Agricola", HomeNumber = "53188007", MobileNumber = "64288907" },
                    new ContactModel { Name = "Luca Rossi", Description = "Meccanico", HomeNumber = "12397089", MobileNumber = "45600978" },
                    new ContactModel { Name = "Luciano Verdi", Description = "Imbianchino", HomeNumber = "32107980", MobileNumber = "24679807" },
                    new ContactModel { Name = "Paolo Bianchi", Description = "Il miglior amico di Luca Rossi", HomeNumber = "13598987", MobileNumber = "65407988" },
                    new ContactModel { Name = "Rossana Vinci", Description = "Cugina di Lucia Verdi", HomeNumber = "12477987", MobileNumber = "35680987" }
                };
            }
        }
    }
}

Messo in piedi il Model, dedichiamoci al Controller che sarà anch’esso estremamente semplice:

namespace Filtering.Controllers
{
    public class ContactsController : Controller
    {
        //
        // GET: /Contacts/
        public ViewResult Index()
        {
            return View(ContactModel.Contacts);
        }
    }
}

Non ci resta ora che aggiungere la relativa View e le fondamenta del progetto sono completate:

@model IEnumerable<Filtering.Models.ContactModel>
@{
    ViewBag.Title = "Contatti";
}
<ul id="list" data-role="listview">
    @{ char startWith = '.'; }
    @foreach (var item in Model)
    {
        if (item.Name[0] != startWith)
        {
            startWith = item.Name[0];
        <li data-role="list-divider">@startWith</li>
        }
        <li>
            <h2>@item.Name</h2>
            <p>@item.Description</p>
            <p>
                Casa: <b>@item.HomeNumber</b>, Cellulare: <b>@item.MobileNumber</b></p>
        </li>
    }
</ul>

Prima di poter avviare il progetto (che a dire il vero ad ora non fa molto) dobbiamo modificare leggermente la mappatura delle Route nel Global.asax.cs per far sì che il nostro unico Controller venga richiamato anche quando richiediamo la root Url del sito:

routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { controller = "Contacts", action = "Index", id = UrlParameter.Optional }
);

Se avviamo il progetto le informazioni del nostro modello vengono visualizzate in una classica lista jQuery Mobile:

Lista base

Da qui possiamo partire per aggiungere il supporto per i filtri. Come quasi tutti saprete, il supporto in questione viene garantito da jQuery Mobile per mezzo dell’attributo data-filter="true" che può essere aggiunto alle liste ordinate (ol) o meno (ul). Aggiungiamo quindi l’attributo alla nostra lista:

<ul id="list" data-role="listview" data-filter="true">

Avviamo nuovamente l’applicazione per verificare che la lista è ora filtrabile per mezzo di un controllo aggiunto automaticamente da jQuery Mobile:

Lista filtrata 1

Non può lasciare indifferenti il fatto che la semplice aggiunta di un attributo ci consente di ottenere con sforzo praticamente nullo una lista con filtro integrato completamente funzionante lato client. Come spesso capita però, le soluzioni a costo zero non offrono tutte le funzionalità necessarie. Con jQuery Mobile la buona notizia è che modificare il funzionamento di base del filtro è semplice e veloce.

Prima di metterci al lavoro vediamo quali sono i problemi cui accennavo. Proviamo a filtrare la lista cercando il testo “bian”:

Lista filtrata 2

Come vediamo le impostazioni di default di jQuery Mobile prevedono che la ricerca venga effettuata su tutto il contenuto text dell’elemento facente parte della lista. Quindi nel nostro caso otterremo sia Bianca Celli e Paolo Bianchi (che corrispondono per nome) che Luciano Verdi (che corrisponde per descrizione). Per quanto questo possa a volte essere esattamente il comportamento voluto, ci sono casi in cui certamente il risultato non è quello desiderato. Ad esempio supponiamo di essere alla ricerca di Bianca Celli e proviamo a filtrare utilizzando questa volta il testo “cel” che corrisponde alle iniziali del cognome:

Lista filtrata 3

Apparentemente sembra che qualcosa sia andato storto e che jQuery Mobile non abbia eseguito alcuna operazione. In realtà il problema è nel testo “cel” che si trova in tutti gli elementi nell’etichetta “Cellulare”. La lista è stata quindi filtrata (come si può facilmente vedere continuando a digitare fino ad ottenere il testo “celli”) ma tutti gli elementi sono risultati perfettamente corrispondenti al testo ricercato.

Fortunatamente jQuery Mobile ci fornisce un modo molto semplice per personalizzare la ricerca nel caso in cui si voglia restringerne il campo di applicazione. Attraverso l’attributo data-filtertext che può essere aggiunto agli elementi (li) delle liste è infatti possibile specificare quale sia il testo di riferimento di ogni singolo elemento. E’ superfluo dire che tale testo può anche non avere nulla a che fare con quanto riportato nella lista, ma in tal caso è bene che l’utente abbia chiaro il funzionamento del filtro.

Torniamo quindi sulla nostra vista è modifichiamola in modo da consentire la ricerca sul solo campo nome:

<li data-filtertext="@item.Name">
    <h2>@item.Name</h2>
    <p>@item.Description</p>
    <p>
        Casa: <b>@item.HomeNumber</b>, Cellulare: <b>@item.MobileNumber</b></p>
</li>

Proviamo ora ad eseguire nuovamente il filtro con I testo “bian” e “cel” per verificare che effettivamente in questo caso la ricerca viene limitata al solo nome:

Lista filtrata 4

L’utilizzo dell’attributo data-filtertext in questo modo ha ovviamente il suo rovescio della medaglia nel fatto che, ad esempio, non possiamo più filtrare per numero di telefono. Ma jQuery Mobile non sarebbe tale se non fosse abbastanza flessibile da consentirci di personalizzare a piacimento il suo comportamento. In realtà il problema appena descritto potrebbe essere risolto in maniera sbrigativa semplicemente concatenando anche il numero di telefono all’interno dell’attributo data-filtertext:

<li data-filtertext="@item.Name|@item.HomeNumber">

Noi però seguiremo un approccio diverso, per far meglio emergere le enormi potenzialità offerte da jQuery Mobile. La chiave di volta è nel filterCallback, ossia nella possibilità di specificare una propria funzione personalizzata che sarà invocata da jQuery Mobile durante l’applicazione del filtro. Sarà quindi nostra responsabilità, attraverso il valore di ritorno della funzione, decidere se ogni singolo elemento dovrà essere incluso o escluso dal filtro applicato.

Cominciamo col definire in maniera più strutturata il testo di ricerca associato ad ogni elemento. Abbiamo detto di voler consentire le operazioni di filtro sia sul nome che sul numero di telefono e il modo migliore per farlo è quello di memorizzare nell’attributo data-filtertext una stringa in formato Json contenente le due informazioni. Per farlo modifichiamo il nostro modello affinché restituisca una sua opportuna rappresentazione in formato Json dopo aver aggiunto al progetto un riferimento alla libreria Json.NET al solito tramite NuGet:

public string ToJson()
{
    return Newtonsoft.Json.JsonConvert.SerializeObject(new { Name = Name, HomeNumber = HomeNumber });
}

Modifichiamo quindi la nostra vista per farle utilizzare il nuovo metodo ToJson() per popolare l’attributo data-filtertext:

<li data-filtertext="@item.ToJson()">

A questo punto non ci resta che scrivere una funzione personalizzata per effettuare il filtro sugli elementi. La funzione in questione deve essere progettata per accettare due parametri: text, ossia il testo associato all’elemento; e searchValue, ossia il testo rispetto al quale si sta eseguendo il filtro. Il valore di ritorno indicherà se l’elemento dovrà essere escluso (false) o incluso (true) nel filtro corrente.

Per cominciare implementiamo la funzione in modo che effettui lo stesso controllo generico effettuato dalla funzione base di jQuery Mobile distinguendo però i due campi memorizzati nella stringa Json. E’ interessante notare che non dobbiamo effettuare alcuna conversione perché il parametro text che giunge alla funzione è già deserializzato come oggetto. E’ quindi necessario comunicare a jQuery Mobile la nostra intenzione di utilizzare la nostra funzione personalizzata al posto di quella di base per eseguire il filtro sugli elementi:

<script type="text/javascript">
    function mySearch(text, searchValue) {
        return (text.Name.toLowerCase().indexOf(searchValue.toLowerCase()) === -1
                && text.HomeNumber.toLowerCase().indexOf(searchValue.toLowerCase()) === -1)
    }
    $(function () {
        $("#list").listview('option', 'filterCallback', mySearch);
    });
</script>

Eseguiamo nuovamente l’applicazione per verificare che è ora effettivamente possibile filtrare l’elenco sia per nome che per numero di telefono. Che succede però se vogliamo filtrare sia per nome che per numero di telefono? Ovviamente grazie al filterCallback il potere è ora tutto nelle nostre mani e possiamo utilizzare qualunque tipo di procedura che a partire dai due parametri in ingresso determini un valore booleano come risultato.

Come detto in apertura del post, cerchiamo di simulare il funzionamento della ricerca di Outlook 2010 che consente di filtrare i messaggi in base a più caratteristiche con la notazione etichetta:(valore ricercato).

Visto che la nostra funzione lato client mySearch è già predisposta a ricevere per ogni elemento un oggetto serializzato Json contenente le due proprietà del contatto, l’unica cosa che ci rimane da fare è interpretare il testo di ricerca digitato dall’utente per estrarre le informazioni relative a nome e numero di telefono. Inutile dire che il modo più rapido ed efficace per farlo è per mezzo delle Regular Expression. In particolare, modificheremo la nostra funzione in modo da cercare all’interno del testo digitato dall’utente i due token nome:(nome ricercato) e tel:(telefono ricercato) e utilizzeremo poi gli eventuali due valori estratti per filtrare gli elementi della lista:

function mySearch(text, searchValue) {
    var result = false;
    var reNome = /(?:\bnome:\(([\w\s]+)(?:\)|$))/;
    var matchNome = reNome.exec(searchValue);
    if (matchNome && matchNome.length > 1)
        result = result || (text.Name.toLowerCase().indexOf(matchNome[1].toLowerCase()) === -1);
    var reTel = /(?:\btel:\(([\d]+)(?:\)|$))/;
    var matchTel = reTel.exec(searchValue);
    if (matchTel && matchTel.length > 1)
        result = result || (text.HomeNumber.toLowerCase().indexOf(matchTel[1].toLowerCase()) === -1);
    return result;
}

Per chi fosse completamente a digiuno di Regular Expression, i caratteri utilizzati per definire reNome hanno più o meno questo significato:

  • (?: indica che questa prima parte dell’espressione dovrà essere ricercata ma non memorizzata;
  • \b indica che il testo successivo dovrà essere l’inizio di una nuova parola;
  • nome:\( indica che il testo dovrà cominciare con “nome:(”;
  • ( indica l’inizio della seconda parte dell’espressione, quella per noi rilevante che utilizzeremo per filtrare gli elementi;
  • [\w\s]+ indica che questa seconda parte potrà essere formata da caratteri alfanumerici (\w) e spazi (\s) in numero di 1 o più (+);
  • ) indica la fine della seconda parte dell’espressione;
  • (?: indica l’inizio della terza parte dell’espressione, anch’essa non memorizzata;
  • \)|$ indica che la terza parte dovrà essere una “)” oppure (|) la fine del testo ($).
  • ) indica la fine della terza parte dell’espressione
  • ) indica la fine dell’espressione nel suo complesso.

Lanciando l’applicazione il risultato ottenuto è esattamente quello desiderato:

Lista filtrata 5

Una volta compreso il meccanismo, non ci sono limiti a ciò che possiamo fare con i filtri di jQuery Mobile se non quelli imposti dalla nostra capacità di scrivere una procedura che partendo dai due parametri in ingresso calcoli il valore booleano di ritorno. O quasi…

C’è infatti un aspetto relativo all’ottimizzazione interna di jQuery Mobile che può influenzare il risultato dei nostri filtri personalizzati in maniera imprevista. Per comprendere di cosa si tratta modifichiamo ulteriormente la nostra funzione di ricerca facendo in modo che l’utente possa digitare una Regular Expression da cercare poi su tutto il testo dell’elemento.

Togliamo quindi l’attributo data-filtertext per tornare alla ricerca su tutto il testo:

<li>
    <h2>@item.Name</h2>
    <p>@item.Description</p>
    <p>
        Casa: <b>@item.HomeNumber</b>, Cellulare: <b>@item.MobileNumber</b></p>
</li>

E modifichiamo la funzione mySearch in modo da utilizzare il testo digitato per costruire una Regular Expression con cui poi validare gli elementi della lista.

function mySearch(text, searchValue) {
    try {
        var re = new RegExp(searchValue, 'i');
        return !re.test(text);
    }
    catch (e) {
        return false;
    }
}

Siamo pronti per lanciare l’applicazione e verificare “l’effetto che fa”. Digitiamo come testo di ricerca “ros|ver” che dovrebbe filtrare tutti contatti che contengo “ros” oppure (|) “ver” nel testo. In realtà il risultato finale comprende solo i contatti che contengono “ros”:

Lista filtrata 6

Il motivo è da ricercarsi nell’ottimizzazione eseguita da jQuery Mobile in fase di filtro. Ad ogni carattere che digitiamo la lista viene infatti filtrata: dopo aver digitato la “r” conterrà quindi solo gli elementi che contengono una “r” nel testo, dopo aver digitato la “o” conterrà quelli che contengono “ro” nel testo e così via. Ad ogni passaggio per velocizzare le operazioni jQuery non processa però tutti gli elementi ma solo quelli ancora visibili basandosi sulla supposizione che aggiungendo un ulteriore carattere al testo di ricerca il filtro non potrà che essere più stringente del precedente. Questa valutazione viene fatta confrontando il valore attuale del testo di ricerca (val) con quello precedente (lastval):

if ( val.length < lastval.length || val.indexOf(lastval) !== 0 ) {

    // Removed chars or pasted something totally different, check all items
    listItems = list.children();
} else {

    // Only chars added, not removed, only use visible subset
    listItems = list.children( ":not(.ui-screen-hidden)" );
}

Per questo motivo, quando digitiamo “ver” gli elementi che lo contengono non vengono aggiunti al filtro visto che, in quanto già non visibili, non vengono ulteriormente processati. E’ però sufficiente cancellare l’ultima “r” per fare in modo che la if restituisca true (val è più corto di lastval) e jQuery Mobile forzi un aggiornamento di tutti gli elementi:

Lista filtrata 7

In effetti l’assunto di jQuery Mobile è assolutamente sensato per la maggior parte delle ricerche “semplici” e diventa inconsistente solo in casi molto particolari come quello di una ricerca basata su Regular Expression in cui un testo più lungo può essere meno restringente di uno più corto.

Per risolvere il problema è sufficiente modificare il codice di base di jQuery Mobile per aggiungere una seconda funzione di call back che ci consenta di specificare a quali elementi vogliamo applicare il nuovo testo di ricerca:

$.mobile.listview.prototype.options.filter = false;
$.mobile.listview.prototype.options.filterPlaceholder = "Filter items...";
$.mobile.listview.prototype.options.filterTheme = "c";
$.mobile.listview.prototype.options.filterApplyToCurrent = function (val, lastval) {
    return ( val.length < lastval.length || val.indexOf(lastval) !== 0 );
};
$.mobile.listview.prototype.options.filterCallback = function( text, searchValue ){
    return text.toLowerCase().indexOf( searchValue ) === -1;
};
if ( listview.options.filterApplyToCurrent(val, lastval ) ) {

    // Removed chars or pasted something totally different, check all items
    listItems = list.children();
} else {

    // Only chars added, not removed, only use visible subset
    listItems = list.children( ":not(.ui-screen-hidden)" );
}

A questo punto possiamo indicare una nostra funzione che tornerà sempre false per forzare il calcolo su tutti gli elementi ad ogni carattere digitato:

$(function () {
    $("#list").listview('option', 'filterCallback', mySearch);
    $("#list").listview('option', 'filterApplyToCurrent', function (val, lastval) { return false; });
});

E ovviamente il filtro risulterà correttamente applicato:

Lista filtrata 8

Per concludere voglio ricordare che il codice utilizzato in questo esempio non è assolutamente ottimizzato nella speranza di renderlo più semplice e chiaro. Ad esempio non ha alcun senso creare l’oggetto RegExp ad ogni chiamata alla funzione mySearch così come bisogna valutare con attenzione – sulla base della grandezza della lista – se sia il caso di processare tutti gli elementi ad ogni carattere digitato o se non sia invece possibile capire, sulla base del valore attuale e precedente, quando sia possibile utilizzare l’insieme già filtrato.

«aprile»
domlunmarmergiovensab
25262728293031
1234567
891011121314
15161718192021
22232425262728
293012345