-
Notifications
You must be signed in to change notification settings - Fork 16
/
ch12-building-a-contacts-app-with-hyperview.typ
1572 lines (1367 loc) · 70.5 KB
/
ch12-building-a-contacts-app-with-hyperview.typ
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#import "lib/definitions.typ": *
== Building A Contacts App With Hyperview
Earlier chapters in this book explained the benefits of building apps using the
hypermedia architecture. These benefits were demonstrated by building a robust
Contacts web application. Then, Chapter 11 argued that hypermedia concepts can
and should be applied to platforms other than the web. We introduced Hyperview
as an example of a hypermedia format and client specifically designed for
building mobile apps. But you may still be wondering: what is it like to create
a fully-featured, production-ready mobile app using Hyperview? Do we have to
learn a whole new language and framework? In this chapter, we will show
Hyperview in action by porting the Contacts web app to a native mobile app. You
will see that many web development techniques (and indeed, much of the code) are
completely identical when developing with Hyperview. How is that possible?
#[
#set enum(numbering: "1.", start: 1)
+ Our Contacts web app was built with the principle of HATEOAS (Hypermedia as the
Engine of Application State). All of the app’s features (retrieving, searching,
editing, and creating contacts) are implemented in the backend (the `Contacts` Python
class). Our mobile app, built with Hyperview, also leverages HATEOAS and relies
on the backend for all of the app’s logic. That means the `Contacts` Python
class can power our mobile app the same way it powers the web app, without any
changes required.
+ The client-server communication in the web app happens using HTTP. The HTTP
server for our web app is written using the Flask framework. Hyperview also uses
HTTP for client-server communication. So we can re-use the Flask routes and
views from the web app for the mobile app as well.
+ The web app uses HTML for its hypermedia format, and Hyperview uses HXML. HTML
and HXML are different formats, but the base syntax is similar (nested tags with
attributes). This means we can use the same templating library (Jinja) for HTML
and HXML. Additionally, many of the concepts of htmx are built into HXML. We can
directly port web app features implemented with htmx (search, infinite loading)
to HXML.
]
Essentially, we can re-use almost everything from the web app backend, but we
will need to replace the HTML templates with HXML templates. Most of the
sections in this chapter will assume we have the web contacts app running
locally and listening on port 5000. Ready? Let’s create new HXML templates for
our mobile app’s UI.
=== Creating a mobile app <_creating_a_mobile_app>
To get started with HXML, there’s one pesky requirement: the Hyperview client.
When developing web applications, you only need to worry about the server
because the client (web browser) is universally available. There’s no equivalent
Hyperview client installed on every mobile device. Instead, we will create our
own Hyperview client, customized to only talk to our server. This client can be
packaged up into an Android or iOS mobile app, and distributed through the
respective app stores.
Luckily, we don’t need to start from scratch to implement a Hyperview client.
The Hyperview code repository comes with a demo backend and a demo client built
using Expo. We will use this demo client but point it to our contacts app
backend as a starting point.
#figure[```bash
git clone git@github.com:Instawork/hyperview.git
cd hyperview/demo
yarn <1>
yarn start <2>
```]
1. Install dependencies for the demo app
2. Start the Expo server to run the mobile app in the iOS simulator.
After running `yarn start`, you will be presented with a prompt asking you to
open the mobile app using an Android emulator or iOS simulator. Select an option
based on which developer SDK you have installed. (The screenshots in this
chapter will be taken from the iOS simulator.) With any luck, you will see the
Expo mobile app installed in the simulator. The mobile app will automatically
launch and show a screen saying
"Network request failed." That’s because by default, this app is configured to
make a request to http:\/\/0.0.0.0:8085/index.xml, but our backend is listening
on port 5000. To fix this, we can make a simple configuration change in the `demo/src/constants.js` file:
#figure[```js
//export const ENTRY_POINT_URL = 'http://0.0.0.0:8085/index.xml'; <1>
export const ENTRY_POINT_URL = 'http://0.0.0.0:5000/'; <2>
```]
1. The default entry point URL in the demo app
2. Setting the URL to point to our contacts app
We’re not up and running yet. With our Hyperview client now pointing to the
right endpoint, we see a different error, a "ParseError." That’s because the
backend is responding to requests with HTML content, but the Hyperview client
expects an XML response (specifically, HXML). So it’s time to turn our attention
to our Flask backend. We will go through the Flask views, and replace the HTML
templates with HXML templates. Specifically, let’s support the following
features in our mobile app:
- A searchable list of contacts
- Viewing the details of a contact
- Editing a contact
- Deleting a contact
- Adding a new contact
#sidebar[Zero Client-Configuration in Hypermedia Applications][
#index[Hyperview][entry-point URL]
For many mobile apps that use the Hyperview client, configuring this entry point
URL is the only on-device code you need to write to deliver a full-featured app.
Think of the entry point URL as the address you type into a web browser to open
a web app. Except in Hyperview, there is no address bar, and the browser is
hard-coded to only open one URL. This URL will load the first screen when a user
launches the app. Every other action the user can take will be declared in the
HXML of that first screen. This minimal configuration is one of the benefits of
the Hypermedia-driven architecture.
Of course, you may want to write more on-device code to support more features in
your mobile app. We will demonstrate how to do that later in this chapter, in
the section called "Extending the Client."
]
=== A Searchable List of Contacts <_a_searchable_list_of_contacts>
We will start building our Hyperview app with the entry point screen, the list
of contacts. For the initial version of this screen, let’s support the following
features from the web app:
- display a scrollable list of contacts
- "search-as-you-type" field above the list
- "infinite-scroll" to load more contacts as the user scrolls through
Additionally, we will add a "pull-to-refresh" interaction on the list, since
users expect this from list UIs in mobile apps.
If you recall, all of the pages in the Contacts web app extended a common base
template, `layout.html`. We need a similar base template for the screens of the
mobile app. This base template will contain the style rules of our UI, and a
basic structure common to all screens. Let’s call it `layout.xml`.
#figure(caption: [Base template `hv/layout.xml`])[ ```xml
<doc xmlns="https://hyperview.org/hyperview">
<screen>
<styles><!-- omitted for brevity --></styles>
<body style="body" safe-area="true">
<header style="header">
{% block header %} <1>
<text style="header-title">Contact.app</text>
{% endblock %}
</header>
<view style="main">
{% block content %}{% endblock %} <2>
</view>
</body>
</screen>
</doc>
``` ]
1. The header section of the template, with a default title.
2. The content section of the template, to be provided by other templates.
We’re using the HXML tags and attributes covered in the previous chapter. This
template sets up a basic screen layout using `<doc>`,
`<screen>`, `<body>`, `<header>`, and `<view>` tags. Note that the HXML syntax
plays well with the Jinja templating library. Here, we’re using Jinja’s blocks
to define two sections (`header` and `content`) that will hold the unique
content of a screen. With our base template completed, we can create a template
specifically for the contacts list screen.
#figure(
caption: [Start of `hv/index.xml`],
)[ ```xml
{% extends 'hv/layout.xml' %} <1>
{% block content %} <2>
<form> <3>
<text-field name="q" value="" placeholder="Search..."
style="search-field" />
<list id="contacts-list"> <4>
{% include 'hv/rows.xml' %}
</list>
</form>
{% endblock %}
``` ]
1. Extend the base layout template
2. Override the `content` block of the layout template
3. Create a search form that will issue an HTTP `GET` to `/contacts`
4. The list of contacts, using a Jinja `include` tag.
This template extends the base `layout.xml`, and overrides the `content`
block with a `<form>`. At first, it might seem strange that the form wraps both
the `<text-field>` and the `<list>` elements. But remember: in Hyperview, the
form data gets included in any request originating from a child element. We will
soon add interactions to the list (pull to refresh) that will require the form
data. Note the use of a Jinja
`include` tag to render the HXML for the rows of contacts in the list (`hv/rows.xml`).
Just like in the HTML templates, we can use the
`include` to break up our HXML into smaller pieces. It also allows the server to
respond with just the `rows.xml` template for interactions like searching,
infinite scroll, and pull-to-refresh.
#figure(caption: [`hv/rows.xml`])[ ```xml
<items xmlns="https://hyperview.org/hyperview"> <1>
{% for contact in contacts %} <2>
<item key="{{ contact.id }}" style="contact-item"> <3>
<text style="contact-item-label">
{% if contact.first %}
{{ contact.first }} {{ contact.last }}
{% elif contact.phone %}
{{ contact.phone }}
{% elif contact.email %}
{{ contact.email }}
{% endif %}
</text>
</item>
{% endfor %}
</items>
``` ]
1. An HXML element that groups a set of `<item>` elements in a common ancestor.
2. Iterate over the contacts that were passed in to the template.
3. Render an `<item>` for each contact, showing the name, phone number, or email.
In the web app, each row in the list showed the contact’s name, phone number,
and email address. But in a mobile app, we have less real-estate. It would be
hard to cram all this information into one line. Instead, the row just shows the
contact’s first and last name, and falls back to email or phone if the name is
not set. To render the row, we again make use of Jinja template syntax to render
dynamic text with data passed to the template.
We now have templates for the base layout, the contacts screen, and the contact
rows. But we still have to update the Flask views to use these templates. Let’s
take a look at the `contacts()` view in its current form, written for the web
app:
#figure(
caption: [`app.py`],
)[ ```py
@app.route("/contacts")
def contacts():
search = request.args.get("q")
page = int(request.args.get("page", 1))
if search:
contacts_set = Contact.search(search)
if request.headers.get('HX-Trigger') == 'search':
return render_template("rows.html",
contacts=contacts_set, page=page)
else:
contacts_set = Contact.all(page)
return render_template("index.html",
contacts=contacts_set, page=page)
``` ]
This view supports fetching a set of contacts based on two query params,
`q` and `page`. It also decides whether to render the full page (`index.html`)
or just the contact rows (`rows.html`) based on the
`HX-Trigger` header. This presents a minor problem. The `HX-Trigger`
header is set by the htmx library; there’s no equivalent feature in Hyperview.
Moreover, there are multiple scenarios in Hyperview that require us to respond
with just the contact rows:
- searching
- pull-to-refresh
- loading the next page of contacts
Since we can’t depend on a header like `HX-Trigger`, we need a different way to
detect if the client needs the full screen or just the rows in the response. We
can do this by introducing a new query param,
`rows_only`. When this param has the value `true`, the view will respond to the
request by rendering the `rows.xml` template. Otherwise, it will respond with
the `index.xml` template:
#figure(
caption: [`app.py`],
)[ ```py
@app.route("/contacts")
def contacts():
search = request.args.get("q")
page = int(request.args.get("page", 1))
rows_only = request.args.get("rows_only") == "true" <1>
if search:
contacts_set = Contact.search(search)
else:
contacts_set = Contact.all(page)
template_name = "hv/rows.xml" if rows_only else "hv/index.xml" <1>
return render_template(template_name,
contacts=contacts_set, page=page)
``` ]
1. Check for a new `rows_only` query param.
2. Render the appropriate HXML template based on `rows_only`.
There’s one more change we have to make. Flask assumes that most views will
respond with HTML. So Flask defaults the `Content-Type` response header to a
value of `text/html`. But the Hyperview client expects to receive HXML content,
indicated by a `Content-Type` response header with value `application/vnd.hyperview+xml`.
The client will reject responses with a different content type. To fix this, we
need to explicitly set the `Content-Type` response header in our Flask views. We
will do this by introducing a new helper function, `render_to_response()`:
#figure(
caption: [`app.py`],
)[ ```py
def render_to_response(template_name, *args, **kwargs):
content = render_template(template_name, *args, **kwargs) <1>
response = make_response(content) <2>
response.headers['Content-Type'] =
'application/vnd.hyperview+xml' <3>
return response
``` ]
1. Renders the given template with the supplied arguments and keyword arguments.
2. Create an explicit response object with the rendered template.
3. Sets the response `Content-Type` header to XML.
As you can see, this helper function uses `render_template()` under the hood. `render_template()` returns
a string. This helper function uses that string to create an explicit `Response` object.
The response object has a `headers` attribute, allowing us to set and change the
response headers. Specifically, `render_to_response()` sets `Content-Type` to
`application/vnd.hyperview+xml` so that the Hyperview client recognizes the
content. This helper is a drop-in replacement for `render_template`
in our views. So all we need to do is update the last line of the
`contacts()` function.
#figure(
caption: [`contacts() function`],
)[ ```py
return render_to_response(template_name,
contacts=contacts_set, page=page) <1>
``` ]
1. Render the HXML template to an XML response.
With these changes to the `contacts()` view, we can finally see the fruits of
our labor. After restarting the backend and refreshing the screen in our mobile
app, we can see the contacts screen!
#figure([#image("images/screenshot_hyperview_list.png")], caption: [
Contacts Screen
])
==== Searching Contacts
#index[Hyperview][search]
So far, we have a mobile app that displays a screen with a list of contacts. But
our UI doesn’t support any interactions. Typing a query in the search field
doesn’t filter the list of contacts. Let’s add a behavior to the search field to
implement a search-as-you-type interaction. This requires expanding `<text-field>` to
add a
`<behavior>` element.
#figure(
caption: [Snippet of `hv/index.xml`],
)[ ```xml
<text-field name="q" value="" placeholder="Search..."
style="search-field">
<behavior
trigger="change" <1>
action="replace-inner" <2>
target="contacts-list" <3>
href="/contacts?rows_only=true" <4>
verb="get" <5>
/>
</text-field>
``` ]
1. This behavior will trigger when the value of the text field changes.
2. When the behavior triggers, the action will replace the content inside the
target element.
3. The target of the action is the element with ID `contacts-list`.
4. The replacement content will be fetched from this URL path.
5. The replacement content will be fetched with the `GET` HTTP method.
The first thing you’ll notice is that we changed the text field from using a
self-closing tag (`<text-field />`) to using opening and closing tags (`<text-field>…</text-field>`).
This allows us to add a child
`<behavior>` element to define an interaction.
The `trigger="change"` attribute tells Hyperview that a change to the value of
the text field will trigger an action. Any time the user edits the content of
the text field by adding or deleting characters, an action will trigger.
The remaining attributes on the `<behavior>` element define the action.
`action="replace-inner"` means the action will update content on the screen, by
replacing the HXML content of an element with new content. For `replace-inner` to
do its thing, we need to know two things: the current element on the screen that
will be targeted by the action, and the content that will used for the
replacement. `target="contacts-list"`
tells us the ID of the current element. Note that we set
`id="contacts-list"` on the `<list>` element in `index.xml`. So when the user
enters a search query into the text field, Hyperview will replace the content of `<list>` (a
bunch of `<item>` elements) with new content (`<item>` elements that match the
search query) received in the relative href response. The domain here is
inferred from the domain used to fetch the screen. Note that `href` includes our `rows_only` query
param; we want the response to only include the rows and not the entire screen.
#figure([#image("images/screenshot_hyperview_search.png")], caption: [
Searching for Contacts
])
That’s all it takes to add search-as-you-type functionality to our mobile app!
As the user types a search query, the client will make requests to the backend
and replace the list with the search results. You may be wondering, how does the
backend know the query to use? The
`href` attribute in the behavior does not include the `q` param expected by our
backend. But remember, in `index.xml`, we wrapped the
`<text-field>` and `<list>` elements with a `<form>` element. The
`<form>` element defines a group of inputs that will be serialized and included
in any HTTP requests triggered by its child elements. In this case, the `<form>` element
surrounds the search behavior and the text field. So the value of the `<text-field>` will
be included in our HTTP request for the search results. Since we are making a `GET` request,
the name and value of the text field will be serialized as a query param. Any
existing query params on the `href` will be preserved. This means the actual
HTTP request to our backend looks like
`GET /contacts?rows_only=true&q=Car`. Our backend already supports the
`q` param for searching, so the response will include rows that match the string "Car."
==== Infinite scroll
#index[Hyperview][infinite scroll]
If the user has hundreds or thousands of contacts, loading them all at once may
result in poor app performance. That’s why most mobile apps with long lists
implement an interaction known as "infinite scroll." The app loads a fixed
number of initial items in the list, let’s say 100 items. If the user scrolls to
the bottom of the list, they see a spinner indicating more content is loading.
Once the content is available, the spinner is replaced with the next page of 100
items. These items are appended to the list, they don’t replace the first set of
items. So the list now contains 200 items. If the user scrolls to the bottom of
the list again, they will see another spinner, and the app will load the next
set of content. Infinite scroll improves app performance in two ways:
- The initial request for 100 items will be processed quickly, with predictable
latency.
- Subsequent requests can also be fast and predictable.
- If the user doesn’t scroll to the bottom of the list, the app won’t have to make
subsequent requests.
Our Flask backend already supports pagination on the `/contacts`
endpoint via the `page` query param. We just need to modify our HXML templates
to make use of this parameter. To do this, let’s edit
`rows.xml` to add a new `<item>` below the Jinja for-loop:
#figure(caption: [Snippet of `hv/rows.xml`])[ ```xml
<items xmlns="https://hyperview.org/hyperview">
{% for contact in contacts %}
<item key="{{ contact.id }}" style="contact-item">
<!-- omitted for brevity -->
</item>
{% endfor %}
{% if contacts|length > 0 %}
<item key="load-more" id="load-more" style="load-more-item"> <1>
<behavior
trigger="visible" <2>
action="replace" <3>
target="load-more" <4>
href="/contacts?rows_only=true&page={{ page + 1 }}" <5>
verb="get"
/>
<spinner /> <6>
</item>
{% endif %}
</items>
``` ]
1. Include an extra `<item>` in the list to show the spinner.
2. The item behavior triggers when visible in the viewport.
3. When triggered, the behavior will replace an element on the screen.
4. The element to be replaced is the item itself (ID `load-more`).
5. Replace the item with the next page of content.
6. The spinner element.
If the current list of contacts passed to the template is empty, we can assume
there’s no more contacts to fetch from the backend. So we use a Jinja
conditional to only include this new `<item>` if the list of contacts is
non-empty. This new `<item>` element gets an ID and a behavior. The behavior
defines the infinite scroll interaction.
Up until now, we’ve seen `trigger` values of `change` and `refresh`. But to
implement infinite scroll, we need a way to trigger the action when the user
scrolls to the bottom of the list. The `visible` trigger can be used for this
exact purpose. It will trigger the action when the element with the behavior is
visible in the device viewport. In this case, the new `<item>` element is the
last item in the list, so the action will trigger when the user scrolls down far
enough for the item to enter the viewport. As soon as the item is visible, the
action will make an HTTP GET request, and replace the loading `<item>` element
with the response content.
Note that our href must include the `rows_only=true` query param, so that our
response will only include HXML for the contact items, and not the entire
screen. Also, we’re passing the `page` query param, incrementing the current
page number to ensure we load the next page.
What happens when there’s more than one page of items? The initial screen will
include the first 100 items, plus the "load-more" item at the bottom. When the
user scrolls to the bottom of the screen, Hyperview will request the second page
of items (`&page=2`), and replace the
"load-more" item with the new items. But this second page of items will include
a new "load-more" item. So once the user scrolls through all of the items from
the second page, Hyperview will again request more items (`&page=3`). And once
again, the "load-more" item will be replaced with the new items. This will
continue until all of the items will be loaded on the screen. At that point,
there will be no more contacts to return, the response will not include another "load-more"
item, and our pagination is over.
==== Pull-to-refresh
#index[Hyperview][pull-to-refresh]
Pull-to-refresh is a common interaction in mobile apps, especially on screens
featuring dynamic content. It works like this: At the top of a scrolling view,
the user pulls the scrolling content downwards with a swipe-down gesture. This
reveals a spinner "below" the content. Pulling the content down sufficiently far
will trigger a refresh. While the content refreshes, the spinner remains visible
on screen, indicating to the user that the action is still taking place. Once
the content is refreshed, the content retracts back up to its default position,
hiding the spinner and letting the user know that the interaction is done.
#figure([#image("images/screenshot_hyperview_refresh_cropped.png")], caption: [
Pull-to-refresh
])
This pattern is so common and useful that it’s built in to Hyperview via the `refresh` action.
Let’s add pull-to-refresh to our list of contacts to see it in action.
#figure(caption: [Snippet of `hv/index.xml`])[ ```xml
<list id="contacts-list"
trigger="refresh" <1>
action="replace-inner" <2>
target="contacts-list" <3>
href="/contacts?rows_only=true" <4>
verb="get" <5>
>
{% include 'hv/rows.xml' %}
</list>
``` ]
1. This behavior will trigger when the user does a "pull-to-refresh" gesture.
2. When the behavior triggers, this action will replace the content inside the
target element.
3. The target of the action is the `<list>` element itself.
4. The replacement content will be fetched from this URL path.
5. The replacement content will be fetched with the `GET` HTTP method.
You’ll notice something unusual in the snippet above: rather than adding a `<behavior>` element
to the `<list>`, we added the behavior attributes directly to the `<list>` element.
This is a shorthand notation that’s sometimes useful for specifying single
behaviors on an element. It is equivalent to adding a `<behavior>` element to
the `<list>` with the same attributes.
So why did we use the shorthand syntax here? It has to do with the action, `replace-inner`.
Remember, this action replaces all child elements of the target with the new
content. This includes `<behavior>`
elements too! Let’s say our `<list>` did contain a `<behavior>`. If the user did
a search or pull-to-refresh, we would replace the content of
`<list>` with the content from `rows.xml`. The `<behavior>` would no longer be
defined on the `<list>`, and subsequent attempts to pull-to-refresh would not
work. By defining the behavior as attributes of `<list>`, the behavior will
persist even when replacing the items in the list. Generally, we prefer to use
explicit `<behavior>` elements in HXML. It makes it easier to define multiple
behaviors, and to move the behavior around while refactoring. But the shorthand
syntax is good to apply in situations like this.
==== Viewing The Details Of A Contact <_viewing_the_details_of_a_contact>
Now that our contacts list screen is in good shape, we can start adding other
screens to our app. The natural next step is to create a details screen, which
appears when the user taps an item in the contacts list. Let’s update the
template that renders the contact `<item>` elements, and add a behavior to show
the details screen.
#figure(
caption: [`hv/rows.xml`],
)[ ```xml
<items xmlns="https://hyperview.org/hyperview">
{% for contact in contacts %}
<item key="{{ contact.id }}" style="contact-item">
<behavior trigger="press" action="push"
href="/contacts/{{ contact.id }}" /> <1>
<text style="contact-item-label">
<!-- omitted for brevity -->
</text>
</item>
{% endfor %}
</items>
``` ]
1. Behavior to push the contact details screen onto the stack when pressed.
Our Flask backend already has a route for serving the contact details at
`/contacts/<contact_id>`. In our template, we use a Jinja variable to
dynamically generate the URL path for the current contact in the for-loop. We
also used the "push" action to show the details by pushing a new screen onto the
stack. If you reload the app, you can now tap any contact in the list, and
Hyperview will open the new screen. However, the new screen will show an error
message. That’s because our backend is still returning HTML in the response, and
the Hyperview client expects HXML. Let’s update the backend to respond with HXML
and the proper headers.
#figure(caption: [`app.py`])[ ```py
@app.route("/contacts/<contact_id>")
def contacts_view(contact_id=0):
contact = Contact.find(contact_id)
return render_to_response("hv/show.xml", contact=contact) <1>
``` ]
1. Generate an XML response from a new template file.
Just like the `contacts()` view, `contacts_view()` uses
`render_to_response()` to set the `Content-Type` header on the response. We’re
also generating the response from a new HXML template, which we can create now:
#figure(
caption: [`hv/show.xml`],
)[ ```xml
{% extends 'hv/layout.xml' %} <1>
{% block header %} <2>
<text style="header-button">
<behavior trigger="press" action="back" /> <3>
Back
</text>
{% endblock %}
{% block content %} <4>
<view style="details">
<text style="contact-name">
{{ contact.first }} {{ contact.last }}
</text>
<view style="contact-section">
<text style="contact-section-label">Phone</text>
<text style="contact-section-info">{{contact.phone}}</text>
</view>
<view style="contact-section">
<text style="contact-section-label">Email</text>
<text style="contact-section-info">{{contact.email}}</text>
</view>
</view>
{% endblock %}
``` ]
1. Extend the base layout template.
2. Override the `header` block of the layout template to include a
"Back" button.
3. Behavior to navigate to the previous screen when pressed.
4. Override the `content` block to show the full details of the selected contact.
The contacts detail screen extends the base `layout.xml` template, just like we
did in `index.xml`. This time, we’re overriding content in both the `header` block
and `content` block. Overriding the header block lets us add a "Back" button
with a behavior. When pressed, the Hyperview client will unwind the navigation
stack and return the user to the contacts list.
Note that triggering this behavior is not the only way to navigate back. The
Hyperview client respects navigation conventions on different platforms. On iOS,
users can also navigate to the previous screen by swiping right from the left
edge of the device. On Android, users can also navigate to the previous screen
by pressing the hardware back button. We don’t need to specify anything extra in
the HXML to get these interactions.
#figure([#image("images/screenshot_hyperview_detail_cropped.png")], caption: [
Contact Details Screen
])
With just a few simple changes, we’ve gone from a single-screen app to a
multi-screen app. Note that we didn’t need to change anything in the actual
mobile app code to support our new screen. This is a big deal. In traditional
mobile app development, adding screens can be a significant task. Developers
need to create the new screen, insert it into the appropriate place of the
navigation hierarchy, and write code to open the new screen from existing
screens. In Hyperview, we just added a behavior with `action="push"`.
=== Editing a Contact <_editing_a_contact>
So far, our app lets us browse a list of contacts, and view details of a
specific contact. Wouldn’t it be nice to update the name, phone number, or email
of a contact? Let’s add UI to edit contacts as our next enhancement.
First we have to figure out how we want to display the editing UI. We could push
a new editing screen onto the stack, the same way we pushed the contact details
screen. But that’s not the best design from a user-experience perspective.
Pushing new screens makes sense when drilling down into data, like going from a
list to a single item. But editing is not a "drill-down" interaction, it’s a
mode switch between viewing and editing. So instead of pushing a new screen,
let’s replace the current screen with the editing UI. That means we need to add
a button and behavior that use the `reload` action. This button can be added to
the header of the contact details screen.
#figure(
caption: [Snippet of `hv/show.xml`],
)[ ```xml
{% block header %}
<text style="header-button">
<behavior trigger="press" action="back" />
Back
</text>
<text style="header-button"> <1>
<behavior trigger="press" action="reload"
href="/contacts/{{contact.id}}/edit" /> <2>
Edit
</text>
{% endblock %}
``` ]
1. The new "Edit" button.
2. Behavior to reload the current screen with the edit screen when pressed.
Once again, we’re reusing an existing Flask route (`/contacts/<contact_id>/edit`)
for the edit UI, and filling in the contact ID using data passed to the Jinja
template. We also need to update the `contacts_edit_get()` view to return an XML
response based on an HXML template (`hv/edit.xml`). We’ll skip the code sample
because the needed changes are identical to what we applied to `contacts_view()` in
the previous section. Instead, let’s focus on the template for the edit screen.
#figure(caption: [`hv/edit.xml`])[ ```xml
{% extends 'hv/layout.xml' %}
{% block header %}
<text style="header-button">
<behavior trigger="press" action="back" href="#" />
Back
</text>
{% endblock %}
{% block content %}
<form> <1>
<view id="form-fields"> <2>
{% include 'hv/form_fields.xml' %} <3>
</view>
<view style="button"> <4>
<behavior
trigger="press"
action="replace-inner"
target="form-fields"
href="/contacts/{{contact.id}}/edit"
verb="post"
/>
<text style="button-label">Save</text>
</view>
</form>
{% endblock %}
``` ]
1. Form wrapping the input fields and buttons.
2. Container with ID, containing the input fields.
3. Template include to render the input fields.
4. Button to submit the form data and update the input fields container.
Since the edit screen needs to send data to the backend, we wrap the entire
content section in a `<form>` element. This ensures the form field data will be
included in the HTTP requests to our backend. Within the `<form>` element, our
UI is divided into two sections: the form fields, and the Save button. The
actual form fields are defined in a separate template (`form_fields.xml`) and
added to the edit screen using a Jinja include tag.
#figure(
caption: [`hv/form_fields.xml`],
)[ ```xml
<view style="edit-group">
<view style="edit-field">
<text-field name="first_name" placeholder="First name"
value="{{ contact.first }}" /> <1>
<text style="edit-field-error">{{ contact.errors.first }}</text> <2>
</view>
<view style="edit-field"> <3>
<text-field name="last_name" placeholder="Last name"
value="{{ contact.last }}" />
<text style="edit-field-error">{{ contact.errors.last }}</text>
</view>
<!-- same markup for contact.email and contact.phone -->
</view>
``` ]
1. Text input holding the current value for the contact’s first name.
2. Text element that could display errors from the contact model.
3. Another text field, this time for the contact’s last name.
We omitted the code for the contact’s phone number and email address, because
they follow the same pattern as the first and last name. Each contact field has
its own `<text-field>`, and a `<text>` element below it to display possible
errors. The `<text-field>` has two important attributes:
- `name` defines the name to use when serializing the text-field’s value into form
data for HTTP requests. We are using the same names as the web app from previous
chapters (`first_name`, `last_name`, `phone`,
`email`). That way, we don’t need to make changes in our backend to parse the
form data.
- `value` defines the pre-filled data in the text field. Since we are editing an
existing contact, it makes sense to pre-fill the text field with the current
name, phone, or email.
You might be wondering, why did we choose to define the form fields in a
separate template (`form_fields.xml`)? To understand that decision, we need to
first discuss the "Save" button. When pressed, the Hyperview client will make an
HTTP `POST` request to `contacts/<contact_id>/edit`, with form data serialized
from the `<text-field>` inputs. The HXML response will replace the contents of
form field container (ID
`form-fields`). But what should that response be? That depends on the validity
of the form data:
1. If the data is invalid (e.g., duplicate email address), our UI will remain in
the editing mode and show error messages on the invalid fields. This allows the
user to correct the errors and try saving again.
1. If the data is valid, our backend will persist the edits, and our UI will switch
back to a display mode (the contact details UI).
So our backend needs to distinguish between a valid and invalid edit. To support
these two scenarios, let’s make some changes to the existing
`contacts_edit_post()` view in the Flask app.
#figure(
caption: [`app.py`],
)[ ```py
@app.route("/contacts/<contact_id>/edit", methods=["POST"])
def contacts_edit_post(contact_id=0):
c = Contact.find(contact_id)
c.update(
request.form['first_name'],
request.form['last_name'],
request.form['phone'],
request.form['email']) <1>
if c.save(): <2>
flash("Updated Contact!")
return render_to_response("hv/form_fields.xml",
contact=c, saved=True) <3>
else:
return render_to_response("hv/form_fields.xml", contact=c) <4>
``` ]
1. Update the contact object from the request’s form data.
2. Attempt to persist the updates. This returns `False` for invalid data.
3. On success, render the form fields template, and pass a `saved` flag to the
template
4. On failure, render the form fields template. Error messages are present on the
contact object.
This view already contains conditional logic based on whether the contact model `save()` succeeds.
If `save()` fails, we render the
`form_fields.xml` template. `contact.errors` will contain error messages for the
invalid fields, which will be rendered into the
`<text style="edit-field-error">` elements. If `save()` succeeds, we will also
render the `form_fields.xml` template. But this time, the template will get a `saved` flag,
indicating success. We will update the template to use this flag to implement
our desired UI: switching the UI back to display mode.
#figure(
caption: [`hv/form_fields.xml`],
)[ ```xml
<view style="edit-group">
{% if saved %} <1>
<behavior
trigger="load" <2>
action="reload" <3>
href="/contacts/{{contact.id}}" <4>
/>
{% endif %}
<view style="edit-field">
<text-field name="first_name" placeholder="First name"
value="{{ contact.first }}" />
<text style="edit-field-error">{{ contact.errors.first }}</text>
</view>
<!-- same markup for the other fields -->
</view>
``` ]
1. Only include this behavior after successfully saving a contact.
2. Trigger the behavior immediately.
3. The behavior will reload the entire screen.
4. The screen will be reloaded with the contact details screen.
The Jinja template conditional ensures that our behavior only renders on
successful saves, and not when the screen first opens (or the user submits
invalid data). On success, the template includes a behavior that triggers
immediately thanks to `trigger="load"`. The action reloads the current screen
with the Contact Details screen (from the
`/contacts/<contact_id>` route).
The result? When the user hits "Save", our backend persists the new contact
data, and the screen switches back to the Details screen. Since the app will
make a new HTTP request to get the contact details, it’s guaranteed to show the
freshly saved edits.
#sidebar[Why Not Redirect?][
You may remember the web app version of this code behaved a little differently.
On a successful save, the view returned
`redirect("/contacts/" + str(contact_id))`. This HTTP redirect would tell the
web browser to navigate to the contact details page.
This approach is not supported in Hyperview. Why? A web app’s navigation stack
is simple: a linear sequence of pages, with only one active page at a time.
Navigation in a mobile app is considerably more complex. Mobile apps use a
nested hierarchy of navigation stacks, modals, and tabs. All screens in this
hierarchy are active, and may be displayed instantly in response to user
actions. In this world, how would the Hyperview client interpret an HTTP
redirect? Should it reload the current screen, push a new one, or navigate to a
screen in the stack with the same URL?
Instead of making a choice that would be suboptimal for many scenarios,
Hyperview takes a different approach. Server-controlled redirects are not
possible, but the backend can render navigation behaviors into the HXML. This is
what we do to switch from the Edit UI to the Details UI in the code above. Think
of these as client-side redirects, or better yet client-side navigations.
]
We now have a working Edit UI in our contacts app. Users can enter the Edit mode
by pressing a button on the contact details screen. In the Edit mode, they can
update the contact’s data and save it to the backend. If the backend rejects the
edits as invalid, the app stays in Edit mode and shows the validation errors. If
the backend accepts and persists the edits, the app will switch back to the
details mode, showing the updated contact data.
Let’s add one more enhancement to the Edit UI. It would be nice to let the user
switch away from the Edit mode without needing to save the contact. This is
typically done by providing a "Cancel" action. We can add this as a new button
below the "Save" button.
#figure(
caption: [Snippet of `hv/edit.xml`],
)[ ```xml
<view style="button">
<behavior trigger="press" action="replace-inner"target="form-fields"
href="/contacts/{{contact.id}}/edit" verb="post" />
<text style="button-label">Save</text>
</view>
<view style="button"> <1>
<behavior
trigger="press"
action="reload" <2>
href="/contacts/{{contact.id}}" <3>
/>
<text style="button-label">Cancel</text>
</view>
``` ]
1. A new Cancel button on the edit screen.
2. When pressed, reload the entire screen.
3. The screen will be reloaded with the contact details screen.
This is the same technique we used to switch from the edit UI to the details UI
upon successfully editing the contact. But pressing "Cancel" will update the UI
faster than pressing "Save." On save, the app will first make a `POST` request
to save the data, and then a `GET` request for the details screen. Cancelling
skips the `POST`, and immediately makes the `GET` request.
#figure([#image("images/screenshot_hyperview_edit.png")], caption: [
Contact Edit Screen
])
==== Updating the Contacts List <_updating_the_contacts_list>
At this point, we can claim to have fully implemented the Edit UI. But there’s a
problem. In fact, if we stopped here, users may even consider the app to be
buggy! Why? It has to do with syncing the app state across multiple screens.
Let’s walk through this series of interactions:
1. Launch the app to the Contacts List.
2. Press on the contact "Joe Blow" to load his Contact Details.
3. Press Edit to switch to the edit mode, and change the contact’s first name to "Joseph."
4. Press Save to switch back to viewing mode. The contact’s name is now
"Joseph Blow."
5. Hit the back button to return to the Contacts List.
Did you catch the issue? Our Contacts list is still showing the same list of
names as when we launched the app. The contact we just renamed to "Joseph" is
still showing up in the list as "Joe." This is a general problem in hypermedia
applications. The client does not have a notion of shared data across different
parts of the UI. Updates in one part of the app will not automatically update
other parts of the app.
Luckily, there’s a solution to this problem in Hyperview: events. Events are
built into the behavior system, and allow lightweight communication between
different parts of the UI.
#sidebar[Event Behaviors][
#index[Hyperview][events]
Events are a client-side feature of Hyperview. In
#link("/client-side-scripting/#_hyperscript")[Client-Side Scripting], we
discussed events while working with HTML, \_hyperscript and the DOM. DOM
Elements will dispatch events as a result of user interactions. Scripts can
listen for these events, and respond to them by running arbitrary JavaScript
code.
Events in Hyperview are a good deal simpler, but they don’t require any
scripting and can be defined declaratively in the HXML. This is done through the
behavior system. Events require adding a new behavior attribute, action type,
and trigger type:
- `event-name`: This attribute of `<behavior>` defines the name of the event that
will either be dispatched or listened for.
- `action="dispatch-event"`: When triggered, this behavior will dispatch an event
with the name defined by the `event-name` attribute. This event is dispatched
globally across the entire Hyperview app.
- `trigger="on-event"`: This behavior will trigger if another behavior in the app
dispatches an event matching the `event-name` attribute.
If a `<behavior>` element uses `action="dispatch-event"` or
`trigger="on-event"`, it must also define an `event-name`. Note that multiple
behaviors can dispatch an event with the same name. Likewise, multiple behaviors
can trigger on the same event name.
Let’s look at this simple behavior:
`<behavior trigger="press" action="toggle" target="container" />`.
Pressing an element containing this behavior will toggle the visibility of an
element with the ID "container". But what if the element we want to toggle is on
a different screen? The "toggle" action and target ID lookup only work on the
current screen, so this solution wouldn’t work. The solution is to create two
behaviors, one on each screen, communicating via events:
- Screen A:
`<behavior trigger="press" action="dispatch-event" event-name="button-pressed" />`
- Screen B:
`<behavior trigger="on-event" event-name="button-pressed" action="toggle" target="container" />`
Pressing an element containing the first behavior (on Screen A) will dispatch an
event with the name "button-pressed". The second behavior (on Screen B) will
trigger on an event with this name, and toggle the visibility of an element with
ID "container".
Events have plenty of uses, but the most common is to inform different screens