Architecting for Web and Mobile

I have been trying to take our existing website and re-work it using Ionic and Vue. While our current website works well as desktop website, it provides a horrible experience in a mobile situation. I am building this without any help or anyone to bounce ideas off so I am not sure if I have missed something fundamental or have just made my life soooo hard with out even realising it. Any feedback or pointers would be most appreciated?

I wanted the site to behave slightly differently between desktop and mobile:

  • in desktop: have a tab bar across the top of the screen with top level navigation. If there are submenus, these appear as a sidebar on the screen
  • in mobile: use the split screen with a hamburger toggle to slide in the top level menu. If there are submenus, these appear as a toolbar along the bottom of the screen.

In my App.vue

<template>
  <ion-app>
    <ion-tabs v-if="isDeskTopAndNotLogin">
        <ion-tab-bar slot="top" class="tabBarHeader">
          <ion-tab-button v-for="page in appPages" :key="page.Pos" :tab="page.Tab" @click="() => router.push(page.URL)">
                <ion-label>{{page.Desc}}&nbsp;</ion-label>
                <ion-badge v-if="page.Count > 0">&nbsp;{{page.Count}}</ion-badge>
            </ion-tab-button>
        </ion-tab-bar>
        <ion-router-outlet></ion-router-outlet>
    </ion-tabs>
    <ion-split-pane v-else content-id="main-content">
      <ion-menu id="ionSplitPaneDiv" content-id="main-content" type="overlay">
        <ion-content>
          <ion-list id="labels-list">
              <ion-menu-toggle auto-hide="false" v-for="(p, i) in appPages" :key="i">
                <ion-item @click="selectedIndex = i" router-direction="root" :router-link="p.URL" lines="none" detail=false class="hydrated"  :class="{ selected: selectedIndex === i }">
                  <ion-label>{{ p.Desc }}</ion-label>
                </ion-item>
              </ion-menu-toggle>
          </ion-list>
        </ion-content>
      </ion-menu>

      <ion-router-outlet id="main-content"></ion-router-outlet>
    </ion-split-pane>
  </ion-app>
</template>

This handles my two top level scenarios, the split pane for mobile and tab bar for desktop.

The routing is while it has some hierarchy but that just matches the menu/submenu options.

import { createRouter, createWebHistory } from '@ionic/vue-router';
import { RouteRecordRaw } from 'vue-router';
import store from '@/other/dataStorage';

const privateRoute = (to: any, from: any, next: any) => {
  const storeage = store();

  storeage.getData<string>("token").then(r => {
      if ( r ) {
        next();
      } else {
        next('login');
      }
  });
};

const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    redirect: '/home'
  },
  {
    path: '/home',
    component: () => import('@/pages/home-page.vue'),
    beforeEnter: privateRoute
  },
  {
    path: '/home/aboutme',
    component: () => import('@/pages/aboutme-page.vue'),
    beforeEnter: privateRoute
  },
  {
    path: '/home/notebook',
    component: () => import('@/pages/notebook-page.vue'),
    beforeEnter: privateRoute
  },
  {
    path: '/diary',
    component: () => import('@/pages/diary-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/diary/calendar',
    component: () => import('@/pages/calendar-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/diary/homework',
    component: () => import('@/pages/homework-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/diary/assignment',
    component: () => import('@/pages/assignment-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/diary/journal',
    component: () => import('@/pages/journal-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/timetable',
    component: () => import('@/pages/timetable-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/message',
    component: () => import('@/pages/messages-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/info',
    component: () => import('@/pages/info-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/settings',
    component: () => import('@/pages/settings-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/settings/resetpassword',
    component: () => import('@/pages/resetpassword-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/settings/wallpaper',
    component: () => import('@/pages/wallpaper-page.vue'),
    beforeEnter: privateRoute
  }, 
  {
    path: '/login',
    component: () => import('@/pages/login-page.vue'),
  },
  { 
    path: '/:pathMatch(.*)', 
    redirect: '/home',
    beforeEnter: privateRoute
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

Then finally as each page is loaded it has a component (base-layout) that handles the submenus as required. The home pages is:

<template>
  <base-layout>
    <GlassPanel class="pictureFrame">
      <div class="holder">
        <ion-item class="someItem ion-text-center" button="false" fill="undefined" line="none">
          <ion-label class="titleTop">{{diaryName}}</ion-label>
        </ion-item>
        <ion-img class="picture" src="https://website.com.au/appimages/coverimages/220419.220448_E04B44CA-DF64-4596-8F43-61C74E802CDB.jpeg"></ion-img>
      </div>
    </GlassPanel>
  </base-layout>
</template>

<script lang="ts">
import { IonItem, IonLabel, IonImg } from '@ionic/vue';
import BaseLayout from '@/components/BaseLayout.vue';
import GlassPanel from '@/components/GlassPanel.vue';
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'home-page',
  components: {
    IonItem,
    IonLabel,
    IonImg,
    BaseLayout,
    GlassPanel
  },
  data() {
    return {
      userData: {
          first: 'James',
          preferred: 'Jimmy',
          frontCover: 'https://website.com.au/appimages/coverimages/220419.220448_E04B44CA-DF64-4596-8F43-61C74E802CDB.jpeg'
      }
    }
  },
  computed: {
    diaryName() {
      const userName = this.userData.preferred;
      const suffix = (userName.toLowerCase().endsWith('s') ? `'` : `'s`) + " Diary";
      return userName + suffix;
    }
  }
    
});
</script>

The baselayout is:

<template>
    <ion-page>
        <HeaderLayout2 v-if="isNotDesktop()"></HeaderLayout2>
        <ion-content class="baseContent">
            <div v-if="isNotDesktop()" style="display: flex; height: 100%;">
                <div class="allContent"><slot /></div>
            </div>
            <div v-else class="withSideBar">
                <SideBar v-if="submenuItems?.length > 0" 
                         :hasLogo="showLogo" 
                         :submenus="submenuItems">
                </SideBar>
                <div class="allContent"><slot /></div>
            </div>
        </ion-content>
        <ToolBar v-show="isNotDesktop() && (submenuItems?.length > 0)" :submenus="submenuItems"></ToolBar>
    </ion-page>
</template>

<script lang="ts">
import HeaderLayout2 from '@/components/HeaderLayout2.vue';
import SideBar from '@/components/SideBar.vue';
import ToolBar from '@/components/ToolBarBottom.vue';
import store from '@/other/dataStorage';
import { getPlatforms, IonPage, IonContent } from '@ionic/vue';
import { defineComponent, ref } from 'vue';

interface MenuItem {
    Pos: string;
    Desc: string;
    URL: string;
    Tab: string;
    Count: number
}
interface UserInterface {
    last: string;
    first: string;
    pref: string;
    cover: string;
    wall: string;
}
interface ColoursInterface {
    clrBackground: string;
    clrHeader: string;
    clrPanels: string;
    clrPromptColor: string;
    clrButtonText: string;
    clrHoverColor: string;
    clrHoverText: string;
    clrContrast: string;
    clrActiveText: string;
    clrTimeTable: string;
    clrPopupPanel: string;
    clrPopupColor: string;
}

export default defineComponent({
    name: 'BaseLayout',
    components: {
        HeaderLayout2,
        SideBar,
        ToolBar,
        IonPage,
        IonContent
    },
    setup() {
        const storeage = store();
        const submenuItems = ref([{}]);
        const backgroundImg = ref('');

        storeage.getData<MenuItem[]>('site').then( r => {
            submenuItems.value = r.filter(i => i.Pos.substr(-1) !== '0' &&  
                                               i.URL.startsWith(window.location.pathname) && 
                                               i.URL !== window.location.pathname );
        });

        storeage.getData<UserInterface>('user').then( r => {
            let wallPaper = '';

            if ( r.wall ) {
                wallPaper = r.wall;
            } else {
                wallPaper = 'Default';
            }
            backgroundImg.value = `url("https://website.com.au/wallpaper/${wallPaper}.png")`;
        });

        storeage.getData<ColoursInterface>('colours').then( i => {
            const root = document.querySelector(':root') as HTMLElement | null;
            root?.style.setProperty('--clrBackground',i.clrBackground);
            root?.style.setProperty('--clrHeader',i.clrHeader);
            root?.style.setProperty('--clrPanels',i.clrPanels);
            root?.style.setProperty('--clrPromptColor',i.clrPromptColor);
            root?.style.setProperty('--clrButtonText',i.clrButtonText);
            root?.style.setProperty('--clrHoverColor',i.clrHoverColor);
            root?.style.setProperty('--clrHoverText',i.clrHoverText);
            root?.style.setProperty('--clrContrast',i.clrContrast);
            root?.style.setProperty('--clrActiveText',i.clrActiveText);
            root?.style.setProperty('--clrTimeTable',i.clrTimeTable);
            root?.style.setProperty('--clrPopupPanel',i.clrPopupPanel);
            root?.style.setProperty('--clrPopupColor',i.clrPopupColor);
        });

        const isNotDesktop = function () : boolean {
            return !getPlatforms().includes("desktop");
        }

console.log('BaseLayout(setup): before return');        
        return {
            isNotDesktop, submenuItems, backgroundImg
        }
    },
    computed: {
      showLogo(): boolean {
        return window.location.pathname === "/home" ? true : false;
      },
    }
});
</script>

<style>
    .baseContent {
        --background: var(--clrBackground) v-bind(backgroundImg) bottom / cover no-repeat
    }

    .withSideBar {
        display: flex;
        height: 100%;
    }
    .allContent {
        padding: 20px;
        flex: 1 1 auto;
        width: 100%;
    }

    .allContent_mobile {
        padding: 12px;
        flex: 1 1 auto;
    }
</style>

Thanks for getting to the end, again any thoughts, tutorials, pointers would be most appreciated. If this is not the right space for this type of post I will remove it.

Hey @peter-app4, did you ever find a solution for using Ionic on web and mobile?

I’m facing a similar issue on Ionic + Vue, would like to show ion-tabs on mobile, but only show ion-toolbar with right side links for the web.

I’ll probably need to hide individual page ion-toolbar to prevent showing double navbars, the back button also doesn’t really make sense on the web either. The downside is I won’t be able to use action buttons on individual page navbars, although will try to use FABs for on page buttons instead.

I’ve searched online but unfortunately can’t really find any good examples how best to target web and mobile with differing navigation functionality.

If you’ve gained any insights since posting, I’d love to hear them. I’ll also post the solution I come up on here as well.

Hey @peter-app4, I came up with a solution to optimize the experience for web and mobile, although I took a different approach than what you proposed above.

In Nuxt 3 I wasn’t able to get things working in app.vue, however have conditional logic in the root page as well as an individual pages.

Basically, for isMobile I show the root ion-tab-bar and in individual pages show ion-header. For isWeb I hide ion-tab-bar and show root ion-toolbar which has static links on right side, and for individual pages I hide ion-header but show any ion-fab buttons.

This way on mobile there’s bottom tabs, whereas on web there’s top navigation with fabs instead, which is a better experience for browsers vs mobile.

app.vue

<template>
  <ion-app>
    <ion-router-outlet />
  </ion-app>
</template>

pages/root.vue

<template>
  <ion-page>
    <ion-header>
      <ion-toolbar v-show="isWeb">
        <ion-title>App</ion-title>
        <ion-buttons slot="end" v-if="currentUser">
          <ion-button @click="showPage(tab.path)" :title="tab.label" :key="tab.name" v-for="tab of tabs">{{ tab.label }}</ion-button>
        </ion-buttons>
        <ion-buttons slot="end" v-else>
          <ion-button href="/login" title="Login">Login</ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>
    <ion-content>
      <ion-tabs>
        <ion-router-outlet></ion-router-outlet>
        <ion-tab-bar :slot="position" v-show="isMobile">
          <ion-tab-button :tab="tab.name" :href="tab.path" :key="tab.name" v-for="tab of tabs">
            <ion-icon :icon="getIcon(tab.icon)" />
            <ion-label>{{ tab.label }}</ion-label>
          </ion-tab-button>
        </ion-tab-bar>
      </ion-tabs>
    </ion-content>
  </ion-page>
</template>

<script setup>
definePageMeta({
  alias: ['/'],
  middleware: 'auth'
})
const currentUser = useCurrentUser();
const { isMobile, isWeb } = usePlatform();
const tabs = [
  {
    name: "products",
    label: "Products",
    path: "/products",
    icon: "shirtOutline"
  }
]
</script>

pages/root/products.vue

<template>
  <ion-page>
    <ion-header :translucent="true" v-if="isMobile">
      <ion-toolbar>
        <ion-title>Products</ion-title>
        <ion-buttons slot="primary">
          <ion-button color="primary" @click="showProductNew">
            <ion-icon slot="icon-only" :icon="ioniconsAdd"></ion-icon>
          </ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>
    <ion-content :fullscreen="true" class="ion-padding">
      <ion-fab slot="fixed" vertical="bottom" horizontal="end" v-if="isWeb">
        <ion-fab-button @click="showProductNew">
          <ion-icon :icon="ioniconsAdd"></ion-icon>
        </ion-fab-button>
      </ion-fab>
    </ion-content>
  </ion-page>
</template>
<script setup>
definePageMeta({
  middleware: 'auth'
})
const { isMobile, isWeb } = usePlatform();
</script>

Here are some screenshots of web vs mobile. Note, on web since I’m hiding individual pages ion-header there’s no longer any back button, so I’m providing that functionality with a ion-breadcrumb instead. Also since I have search bar inside, I’m showing it on mobile.

Web
ionic-web

Mobile
ionic-mobile

This approach seems like it should work, I’ll share any issues I run into along the way.

Sorry for not responding sooner @dalezak the last couple of weeks have been the busiest ever. Well done for finding a solution that works for you, I will have a look over the weekend.

Looking back, I think I had another crack and re-wrote the how app works (again) since this post but will check my code and respond.

1 Like

Hi @peter-app4, would you be able to share your final solution? I’m trying to add ion-split-pane into my project, however running into lots of issues getting it to work with Vue and Nuxt.

@ionic_vue.js?v=2a56da03:11354 Menu: must have a “content” element to listen for drag events on.

Hydration node mismatch: Client vnode: ion-toolbar Server rendered DOM: at <IonToolbar color="tertiary" > at <IonHeader> at <IonMenu content-id="main" id="main-menu" > at <IonSplitPane content-id="main" > at <IonApp> at <App key=3 > at <NuxtRoot>

The ion-split-pane is complaining it doesn’t have a main although content-id is defined, also the ion-menu is having issues with ion-toolbar.

<template>
  <ion-app>
    <ion-split-pane content-id="main">
      <ion-menu content-id="main" type="overlay" side="end">
        <ion-header>
          <ion-toolbar>
            <ion-title>Menu</ion-title>
          </ion-toolbar>
        </ion-header>
        <ion-content></ion-content>
      </ion-menu>
      <ion-router-outlet id="main"></ion-router-outlet>
    </ion-split-pane>
  </ion-app>
</template>

@dalezak this is where I have currently landed with my setup. I have tried to include all the relevant bits but if there is anything missing please let me know and I will update

App.vue

<template>
  <ion-app v-if="whatStyle === 'login'" class="backgroundStuff">
    <!-- Login Style -->
    <ion-router-outlet id="login-content" />
  </ion-app>

  <ion-app v-else-if="whatStyle === 'small'" class="backgroundStuff">
    <!-- Small screen -->
    <ion-menu content-id="main-content">
      <ion-header>
        <ion-toolbar class="headerBar">
          <ion-title>App4 Diary</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content class="ion-padding">
        <ion-menu-toggle auto-hide="true"> 
        <ion-item v-for="but in store.smallMenu" 
                  :lines="but.Pos.endsWith('0') ? 'inset' : 'none' "
                  :key="but.Pos"
                  @click="() => clickMenuButton(but.URL)"
                  class="itemMenu">
          <ion-label>
            {{but.Pos.endsWith('0') ? '' : '&nbsp;&nbsp;&nbsp;&nbsp;'}}{{ but.Desc }}
          </ion-label>
          <ion-badge v-if="but.Count > 0">{{but.Count}}</ion-badge>
        </ion-item>
        </ion-menu-toggle>
      </ion-content>
    </ion-menu>
    <ion-router-outlet id="main-content" />
  </ion-app>

  <ion-app v-else class="backgroundStuff">
    <!-- Rest of the screens -->
    <ion-tabs>
        <ion-tab-bar class="headerBar" slot="top" translucent="true" mode="ios">
          <ion-tab-button v-for="page in store.topLevel" 
                          :key="page.Pos" 
                          :tab="page.Tab" 
                          @click="() => clickMenuButton(page.URL)"
                          :disableButton="[ isCurrentPage(page.URL) ? 'disableButton' : '' ]">
            <ion-label>{{page.Desc}}&nbsp;</ion-label>
            <ion-badge v-if="page.Count > 0">{{page.Count}}</ion-badge>
          </ion-tab-button>
        </ion-tab-bar>
        <ion-router-outlet />
    </ion-tabs>
  </ion-app> 
</template>

<script lang="ts">
  import { IonApp, IonRouterOutlet, useIonRouter } from '@ionic/vue';
  import { IonContent, IonHeader, IonMenu, IonMenuToggle, IonTitle, IonToolbar, IonTabs, IonTabBar, IonTabButton, IonLabel, IonBadge, IonItem } from '@ionic/vue';
  import { defineComponent, reactive, toRefs, computed } from 'vue';
  import { useUserStore } from '@/stores/UserStore';

  export default defineComponent({
    name: 'App',
    components: {
      IonApp, IonRouterOutlet,
      IonContent, IonHeader, IonMenu, IonMenuToggle, IonTitle, IonToolbar, IonTabs, IonTabBar, IonTabButton, IonLabel, IonBadge, IonItem
    },
    setup() {
      const store = useUserStore();
      store.schoolID = (window.location.href.substring(0,7) === 'http://') ? 'peter' : window.location.href.split('//')[1].split('.')[0];

      const state = reactive({
        width: window.innerWidth
      });

      const backgroundImg = computed( () => {
        if ( store.wallpaper) {
          return `url(https://mywebsite.com.au/wallpaper/${store.wallpaperpath}${store.wallpaper}.webp)`
        } else {
          return ''
        }
      })

      const whatStyle = computed( () => {
        if ( store.deviceType ) {
          return store.deviceType
        } else {
          return 'normal'
        }
      })

      const router = useIonRouter();

      const handleResize = () => {
        store.checkDeviceType()
      }
      window.addEventListener('resize', handleResize);

      handleResize();

      const isCurrentPage = (url: string) => store.currentPage.startsWith(url)

      const clickMenuButton = (url?: string) => {
          if ( url === '/diary' ) {
              router.push('/diary/calendar')
          } else {
              router.push(url)
          }
      }

      return {
        ...toRefs(state), router, backgroundImg, whatStyle, store, isCurrentPage, clickMenuButton
      }
    }
  })
</script>

<style>
  .backgroundStuff {
    background: var(--clrBackground) v-bind(backgroundImg) bottom / cover no-repeat;
  }

  .backgroundStuff3 {
    --background: unset;
  }

  .headerBar {
    --background: var(--clrPanels);
    backdrop-filter: blur(3px);    
  }
  ion-tab-button {
    --background: transparent;
    color: black;
    font-size: 14pt;
  }

  .itemMenu:hover {
    cursor: pointer;
    color: var(--clrContrast);
    --background: var(--clrBackground);
  }

  [disableButton=disableButton] {
    pointer-events: none;
    color: white;
    text-shadow: 0 0 4px var(--clrButtonText);
  }

  ion-popover.select-popover {
    --width: unset;
  }
</style>

Here is an example of one of the pages, the Home page. While this page does not directly deal with the screen sizes itself, it passes that responsibility to one of the components (App4Page, which code will follow)

HomePage.vue

<template>
  <App4Page :isSmall="store.deviceType === 'small'">
    <App4GlassPanel style="max-width: 500px; width: 100%">
      <ion-item lines="none" style="--background: transparent">
        <ion-label style="text-align: center; font-size: 2rem; font-weight: 500;">{{ store.coverName }}</ion-label>
      </ion-item>
      <ion-img :src="coverImage" style="border: 8px solid white;"/>
    </App4GlassPanel>     
  </App4Page>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';
import { onIonViewWillEnter, IonItem, IonLabel, IonImg } from '@ionic/vue'
import App4Page from '../components/App4Page.vue'
import { useUserStore } from '@/stores/UserStore';
import App4GlassPanel from '@/components/App4GlassPanel.vue';


export default defineComponent({
  name: 'HomePage',
  components: {
    IonItem, IonLabel, IonImg,
    App4Page, App4GlassPanel
  },
  setup() {
    const store = useUserStore()
    const coverImage = ref('')

    coverImage.value = `https://${store.schoolID}.app4.ws/${store.cover}`
    console.log("Home")

    onIonViewWillEnter( () => {
      store.checkDeviceType();
    })

    return {
      store, coverImage
    }
  }
});
</script>

App4Page.vue (the App4Page component)

<template>
    <ion-page>
        <ion-header v-if="isSmall">
            <ion-toolbar>
                <ion-buttons slot="start">
                    <ion-menu-button></ion-menu-button>
                </ion-buttons>
                <ion-title>{{ store.menuName }}</ion-title>
            </ion-toolbar>
        </ion-header>

        <ion-content class="backgroundStuff3" style="--padding-top: 20px; --padding-bottom: 20px; --padding-start: 20px; --padding-end: 20px;">
          <div id="contentContainer" :class="{fullScreen: $props.isFullScreen}">
            <div id="sideMenu" v-if="(!isSmall || store.currentPage === '/settings') && store.sideMenu?.length > 0">
              <img v-if="store.currentPage === '/home'" id="schoolLogo" :src="'https://' + store.schoolID + '.app4.ws/branding/app4college.png'" :srcset="'https://' + store.schoolID + '.app4.ws/branding/app4college@2x.png 2x'" alt="School Logo" style="margin-bottom: 20px; width: 100%; object-fit: contain;" />
              <SideMenuButton v-for="but in store.sideMenu" :key="but.Pos" :desc="but.Desc" :URL="but.URL" />
            </div>
            <div id="mainBody" :class="{centered: isCentered}">
              <slot />
            </div>
          </div>
        </ion-content>
    </ion-page>
</template>
  
<script lang="ts">
  import { IonPage, IonContent, IonHeader, IonToolbar, IonButtons, IonMenuButton, IonTitle } from '@ionic/vue';
  import { defineComponent } from 'vue';
  import { useUserStore } from '@/stores/UserStore';
  import SideMenuButton from './SideMenuButton.vue';

  export default defineComponent({
    components: {
      IonPage, IonContent, IonHeader, IonToolbar, IonButtons, IonMenuButton, IonTitle,
      SideMenuButton
    },
    props: {
      isSmall: {
        type: Boolean
      },
      isFullScreen: {
        type: String
      },
      isCentered : {
        type: Boolean,
        default: false
      }
    },
    setup() {
      const store = useUserStore()

      return {
        store
      }
    }
  });
</script>

<style scoped>
    ion-item {
        --background: transparent;
    }

    #contentContainer {
      display: flex;
      flex-direction: row;

      max-height: 100%;
    }

    .fullScreen {
      height: 100%;
    }

    #sideMenu {
      flex: 0 0 180px;
      margin-right: 30px;
    }

    #sideMenu > div:first-of-type {
      margin-top: 20px;
    }

    #mainBody {
      flex: 1 1 auto;
      /* padding: 0 20px; */
      display: flex;
      /* justify-content: center; */
      
      width: 100%;
    }

    .centered {
      justify-content: center;
    }
</style>
1 Like