Wednesday, January 03, 2024

UI5 Tips: Persistent UI State

This tip provides a way to centrally manage UI state, and to persist it - automatically and without requiring intrusive custom code sprinkled through your apps.

The UI State

Many ui5 controls and widgets allow some aspect of their appearance or behavior to be changed by the user. For example, a panel may be collapsed or expanded, a tab may be selected, columns width in a data grid may be adjused, and so on. We call all this the ui state. When the user restarts the app, normally, the ui state is reset: properties that were explicitly set are reinitialized to that value, and properties that were not explicitly assigned will get assigned some default, which may either be a constant or some calculated value, depending on how the component is coded. A reset of the ui state may not always be desirable. For example, if the user has to go through multiple clicks and selections before they arrive at a certain item inside the application that interests them, then it will be frustrating if they have to repeat the sequence the next time they open the application. Fortunately, for these use cases, UI5 offers routing and navigation, which lets the user find content inside the application by navigation to a particular url. However, not all ui state is about navigation. For example, the user may collapse a panel to get a bit more screen real estate, or resize the width of a column in a data grid, or toggle the state of a checkbox that controls some application-wide setting. These cases are clearly not navigational in nature, but have to do with layout and presentation. It would be confusing for the user to control this by visiting a particular url. Rather, we'd like the application to be able to retain the ui state exactly as the user left it. In this tip we will explain a way to achieve this, and in a way that does not require any specific application code. It can all

Sample Application

The sample application for this tip is in the uistate directory. Simply expose the contents of the directory with your webserver and use your browser to navigate to index.html. A screenshot is shown below: Screenshot of the UI State App

Sample Application Features

The application has the following features: Users can click on a company in the sidebar to select it, and then the company will be shown in more detail in the Detail Page. The Detail Page has some features of its own:
  • In the top, there's a sap.m.Panel which shows the Company Name as title. The Panel is expandable and expanded by default. Inside the panel we can see the company's phone number. Below the Panel, there's an sap.m.IconTabBar with 2 tabs:
  • Details, which shows the address of the currently selected company. This tab is also selected by default.
  • Departments, which shows the departments of the selected company.

Sample Application Demo

To test the application, try the following sequence of actions:
  1. Use the browser to navigate to the index.html page. The sidebar should show the list op Companies, but no company will be selected yet. You can click any row in the sidebar to select a company, and if you do its details will be shown in the detailpage. For the demonstration it doesn't matter if you select one or not.
  2. In the sidebar, the Name column is not wide enough to show the full company name Euismod Ac Fermentum Corp.. Adjust the width of the column by dragging the right end of its header to the right until the full company name is visible.
  3. Also in the sidebar, the Country column is not wide enough to show the full name of the country Congo, the Democratic Republic of the. Adjust of that column too so the full name is visible.
  4. After adjusting the column width in step 2. and 3., the sidebar will now have a horizontal scrollbar at the bottom, as the data grid is now wider than the position of the splitter grip. Drag the Splitter grip to the right so both columns of the sidebar are visible and the sidebar's horizontal scrollbar disappears again.
  5. The Panel is expaned and the company's phone number is visible inside the panel. Click the button to left of the panel header title to collapse it.
  6. The Details tab is selected by default. Click the Departments tab instead.
After all these actions, the application should now look something more like this: Application after changing the UI State If you now refresh the browser window (or even close the browser alltogether) and then revisit the application, you will notice that the selection is lost. However, the column widths, the position of the splitter grip, the collapsed state of the panel and the selected tab have all been preserved. (You can restore the UI to the original state by pressing the Undo button in the top of the main page.)

Using the [LocalStorageJSONModel] to manage UI State

The UI State behavior is the result of binding all the relevant properties of the UI to a LocalStorageJSONModel. The model is declared in sample application's manifest.json, so it's instantiated automatically as the application starts, and becomes available throughout the application as uistate.
  "uistate": {
    "type": "ui5tips.utils.LocalStorageJSONModel",
    "dataSource": "uistateTemplate"
  }
The model is initialized with the uistateTemplate data source:
  "uistateTemplate": {
    "uri": "data/uistateTemplate.json",
    "type": "JSON"
  }
The datasource grabs its data from data/uistateTemplate.json:
{
  "autoSaveTimeout": 1000,
  "storagePrefix": "uistate",
  "template": {
    "appSettings": {
      "sidebar": {
        "splitterSize": "431px",
        "columns": {
          "name": {
            "width": "190px"
          },
          "country": {
            "width": "190px"
          }
        }
      },
      "detailpage": {
        "panel": {
          "expanded": true
        },
        "tabContainer": {
          "selectedTab": "details" 
        }
      }
    }
  }
}
The options for the LocalStorageJSONModel are described in the LocalStorageJSONModel wiki page. For the sample app, the autoSaveTimeout is relevant - for this example, the value is assigned 1000 and this means that if the state of the model is changed, there will be a 1000 ms (1 second) waiting period after which the data from the model is persisted in the Browser's local storage. The template represents the default initial state of the UI. Please refer to the the LocalStorageJSONModel wiki page for a detailed discussion of the template and model initialization.

Managing Binding

The LocalStorageJSONModel wiki page has some general remarks and clarifications about UI5 data binding. But it may not be entirely clear how to practically organize it to manage UI state. After all, even a simple example like the sample application we discuss here already has 5 distinct UI properties that the user can change.

Template Structure

One could, in principle, make one big property bag to to manage each and every UI property, and this may be the right choice if the application remains really simple. But as an application gets more features, more views and more functionality one may prefer to design a data structure that mimics the structure of the application, and that's the approach we have taken in this example. If you look at data/uistateTemplate.json, you'll notice that the template contains a applicationConfig key which itself has two keys: one to maintain all settings for the sidebar and one for all settings for the detailpage:
  "template": {
    "appSettings": {
      "sidebar": {
        ...
      },
      "detailpage": {
        ...
      }
    }
  }
To these keys, an object is assigned which may have a further hierarchical structure, depending on the UI control tree.

Template Structure and UI Tree Structure

Once we decide to structure the template hierarchically, it may be tempting to attempt to faithfully mimic the actual container/component structure of the UI in the model structure. However at this point it is my opinion that this is not necessary and not productive. The reason to warn against a too tight mapping of the UI tree to the model structure is that the UI tree is only to some extent a reflection of the functional organization of an application's parts. A simple example from the sample application may illustrate this. For example, let's take look at the structure of the template that manages the settings for the detail page:
  "detailpage": {
    "panel": {
      "expanded": true
    },
    "tabContainer": {
      "selectedTab": "details" 
    }
  }
Let's compare this to the ui tree of the detail page, which is defined in DetailPage.view.xml
  <layout:FixFlex binding="{uistate>detailpage}">
    <layout:fixContent>
      <m:Panel
        expandable="true"
        expandAnimation="false"
        binding="{uistate>panel}"
        expanded="{uistate>expanded}"        
        headerText="{companies>CompanyName}"
      >
        <m:content>
          <m:Text text="Phone: {companies>Phone}"/>
        </m:content>
      </m:Panel>
    </layout:fixContent>
    <layout:flexContent>
      <m:IconTabBar
        stretchContentHeight="true"
        applyContentPadding="false"
        expandable="false"
        binding="{uistate>tabContainer}"
        selectedKey="{uistate>selectedTab}"
      >
        <m:items>                                                                     
          <core:Fragment fragmentName="ui5tips.components.detailpage.DetailsIconTabFilter" type="XML" />
          <core:Fragment fragmentName="ui5tips.components.detailpage.DepartmentsIconTabFilter" type="XML" />
        </m:items>
      </m:IconTabBar>
    </layout:flexContent>
  </layout:FixFlex>
While we refer to it as detail page, the actual UI component that is used to implement it is a sap.ui.layout.FixFlex. But it might just as well have been another type of container, like, say, a sap.m.Page. Another example might be the tabs. In this sample application we chose the sap.m.IconTabBar and in the future we might change that to the sap.m.TabContainer. While these are functionally similar components, certain details, like property names and aggregation names might be different. During normal application development, changing and rewriting the UI, swapping out particular containers for other types of containers is quite common. Often, much of the functional aspects are retained and expanded, even though the details of the implementation and choice and structuring of UI components may be quite different. If our template would mimic the UI structure too closely, we would have to modify our template also, and often without any real benefit. So the recommendation here is to make sure the template is organized according to functionality, not to the exact details of the UI tree.

Managing Binding Paths with Element Binding

Once you settle for a hierarchical template structure, the problem arises of how to deal with the paths. For example, consider the selected tab in the the detail page:
  "template": {
    "appSettings": {
      "detailpage": {
        "tabContainer": {
          "selectedTab": "details" 
        }
      }
    }
  }
If we would have to write a path for this in a databinding, we would get uistate>/appSettings/detailpage/tabContainer/selectedTab. Obviously, in a realistic application we would have many properties and the UI code would soon become littered with these very long paths. Element binding is a UI5 feature that lets you bind a particular path from the model to a UI container or control, thus establishing a scope. Witin that scope, you can use relative paths, which UI5 will resolve against the path bound at the higher level. To understand this feature fully it's best to first look at the component that sits at the top of the UI tree - App.view.xml:
  <m:App 
    id="app"
    binding="{uistate>/appSettings}"
  >
    <m:pages>
      <mvc:XMLView viewName="ui5tips.components.mainpage.MainPage"/>
    </m:pages>
  </m:App>
Note how the binding property on the sap.m.App is bound to uistate>/appSettings. What this means is that relative bindings for the uistate model that occur on that component itself, but also to any components that are nested within it, will be resolved against uistate>/appSettings. MainPage.view.xml is nested inside the sap.m.App. If we look at that:
  <m:Page title="UIState App">
    ...
    <layout:Splitter>
      <mvc:XMLView id="sideBar" viewName="ui5tips.components.sidebar.SideBar">
        <mvc:layoutData>
          <layout:SplitterLayoutData size="{uistate>sidebar/splitterSize}"/>
        </mvc:layoutData> 
      </mvc:XMLView>
      <mvc:XMLView id="detailPage" viewName="ui5tips.components.detailpage.DetailPage"/>
    </layout:Splitter>
  </m:Page>
You might notice one bound property to the uistate model here - it is the size property of the sap.ui.layout.SplitterLayoutData object, which is bound to uistate>sidebar/splitterSize. As you can see, that binding refers also to the uistate model, but as it does not start with a /, it is a relative path. So, UI5 will try to resolve it by going up the UI tree, and it will then find the scope from the uistate model that is established by the element binding in App.view.xml. In App.view.xml, the binding was to uistate>/appSettings. If we resolve uistate>sidebar/splitterSize against that, the effective path will become uistate>/appSettings/sidebar/splitterSize. If you look back at the earlier example of DetailPage.view.xml, we notice that its top sap.layout.FixFlex component was bound to uistate>detailpage, effectively letting all relative bindings to the uistate model inside it to be resolved to uistate>/appSettings/detailpage. Of course, element binding does not only let you use shorter paths, using them consitently will also make it much easier to maintain the structure of the template. Whenever there is a radical change of structure, you should be able to rewire the element bindings without having to change each and every property individually.

Finally

Did you like this tip? Do you have a better tip? Feel free to post a comment and share your approach to the same or similar problem. Want more tips? Find other posts with the ui5tips tag!

No comments:

DuckDB bag of tricks: Processing PGN chess games with DuckDB - Rolling up each game's lines into a single game row (6/6)

DuckDB bag of tricks is the banner I use on this blog to post my tips and tricks about DuckDB . This post is the sixth installment of a s...