Master detail view

Hi all!

I’m developing an app for PC, tab and mobile, which consists in a list of items. Each item has a detail page. The idea is to display each item’s detail page when the user clicks on that specific item, but with different behaviour depending on the device. In PC/tablet, the detail view will be displayed on the right side of the list, and in mobile the detail page will be opened in a new page. You can see the idea in this image:

The image was made by Martin Pritchard, who wrote this Medium post some years ago about how to solve the same problem I’m facing now. He used IonSplitPane in Ionic 3 with Angular. I’m afraid I can’t use this approach, or at least I’m not able to replicate it with Vue.

I’ve tried IonSplitPane alongside with IonMenu, but the behaviour is not the expected, because I don’t need a ‘hamburguer’ menu that collapses in mobile view. Besides, I’ve tried with IonSplitPane only and I’m not able to put the collapsible side at the end (as it should be the detail view which hides in mobile, and not the left (master) pane). I’m not even sure this could be a solution, as I need that in mobile the detail page displays in a new view.

Has anyone faced this problem and could help me?

Thank you very much in advance.

Quick reaction…

You could still use the IonSplitPane setup to achieve this. The trick would be to hide the menu toggle button on mobile and render the menu list as your mobile home page content.

To keep things DRY, you’d use the same component to output your list (home page content on mobile, side menu on desktop).

A quick setup could look likes this:

App.vue

<!-- App.vue -->
<template>
	<ion-app>		
		<ion-split-pane content-id="main">
			<!--  the side menu  -->
			<the-side-menu v-if="!isMobile" />

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

<script lang="ts">
import { IonApp, IonRouterOutlet, IonSplitPane, isPlatform } from "@ionic/vue";
import { defineComponent } from "vue";

import MenuItems from "@/components/MenuItems.vue";

export default defineComponent({
    name: "App",
    
	components: {
		IonApp,
		IonRouterOutlet,
		MenuItems,
		IonSplitPane,
	},

	setup() {
        return {
            isMobile: isPlatform("mobile"),
        }
    }
});
</script>

The side menu (visible on desktop)

<!-- components/TheSideMenu.vue -->
<template>
	<ion-menu content-id="main">
		<ion-content>
			<menu-items />
		</ion-content>
	</ion-menu>
</template>

<script>
import { defineComponent } from "vue";

import { IonContent } from "@ionic/vue";

import MenuItems from "@/components/MenuItems.vue";

export default defineComponent({
       name: "TheSideMenu",
	components: {
		IonContent,
		MenuItems,
	},
});
</script>

The shared menu

<!-- components/MenuItems.vue -->
<template>
	<ion-list>
		<ion-item
			v-for="page in pages"
			:key="page.name"
			@click.prevent="navigate(page.routeName)"
			button
		>
			<ion-label>
				<h3>{{ page.title }}</h3>
				<p>{{ page.description }}</p>
			</ion-label>
		</ion-item>
	</ion-list>
</template>

<script>
import { IonList, IonItem, isPlatform } from "@ionic/vue";
import { useRouter, useRoute } from "vue-router";

export default {
	name: "MenuItems",

	setup() {
		const router = useRouter();
		const navigate = (location) => router.push(location);

		const pages = [
			{
				name: "item1",
				title: "Item one title",
				description: "Item one description",
				location: "itemone",
			},
			{
				name: "item2",
				title: "Item two title",
				description: "Item two description",
				location: "itetow",
			},
			{
				name: "item3",
				title: "Item three title",
				description: "Item thre description",
				location: "itemthree",
			},
		];

		return { pages, navigate };
	},
};
</script>

Home page content

<!-- views/Home.vue -->
<template>
    <!-- render list for mobile only -->
	<menu-items v-if="isMobile" />

    <!-- otherwise render desktop content -->
    <div v-else>
        Your deskptop home page content
    </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import MenuItems from "@/components/MenuItems.vue";

export default defineComponent({
	name: "Home",

	components: {
		MenuItems,
	},

	setup() {
		return {
			isMobile: isPlatform("mobile"),
		};
	},
});
</script>

I didn’t test it, but it should hopefully set you on the right path

2 Likes

Hi Treigh,

Thank you very much for your advices. As you said, the best approach seems to be using the SplitPane component. However, I still have doubts about your solution:

  1. I can’t see the point of the Home.vue page. In your example, I assume the App.vue is the root page, so, where and when is the Home page imported and used?

  2. In my app, the TheSideMenu.vue has its own functionality, with buttons and routing implemented. Is this incompatible with IonSplitPane? I’ve tried it and could not make them work.

Related to this, I have to say that in my case, the Side Menu is the main component of the app. It has a Header with IonTabs (the reason why I need routing working in this view) and an IonSegment with four segments (one per list).

My point is that this view is very complex. So, as I said before, I don’t know if the SplitPane is a good solution for me. Maybe with a custom component that displays when clicking a row is easier and wouldn’t interfere with the rest of functionalities of the app. The disadvantage of this is that I would have to develop another solution for mobile devices.

  1. How could I route the different item’s details view? I’ve tried to create the route in my router index.js like this: path: '/notification/:id', and tried to send it as a param of each of the items of the list. But I can’t see the detail view in the right-side pane.
    Note that I have the details views already implemented, so I just need to pass them the item to display.

Thank you again for your help! It’s been really important to me.

All the best.

I like this approach, @treigh. You can even pass props to menu-items if you need a dynamic menu, which makes it super reusable.

Here is a one-page solution is using a modal, media queries, and clientWidth…

Use a grid system with media queries to only display details pane if its desktop. The nice thing about this is when <ion-col size="9"> is hidden, the remaining column will be full width.

Main Page

<ion-grid>
	<ion-row>
		<ion-col>
			// menu list
		</ion-col>
		<ion-col size="9" class="split-pane-desktop-only">
		</ion-col>
	</ion-row>
</ion-grid>

Media Query - <ion-col size="9"> is hidden only if its mobile.

@media (min-width: 320px) and (max-width: 480px) {
  
  .split-pane-desktop-only {
      display: none;
  }
  
}

Then, every time a menu item is clicked you must check the device width to know whether you need to display the details next to the menu (desktop view) or in a modal (mobile view). The main thing here is to know when to open the modal.

Main Page

<ion-item v-for="(item, index) in list" :key="index" detail @click="showDetail(item)">
	<ion-label>{{ item.label }}</ion-label>
</ion-item>
....
methods: {
	async showDetail(item) {
		// document.documentElement.clientWidth => device size
		// anything > 420 is not mobile
		console.log(document.documentElement.clientWidth);
		if (document.documentElement.clientWidth > 420) {
			// if you have to fetch data from an API when item is clicked
			// onSuccess assign data to this.detail
			this.detail = item.detail;
		} else {
			const modal = await modalController.create({
				component: MasterDetail,
				componentProps: {
					data: item.detail,
				},
			});

			return modal.present();
		}
	}
},

Modal

<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-icon :icon="chevronBackOutline" @click="closeForm"></ion-icon>
          Detail
      </ion-toolbar>
    </ion-header>
    <ion-content class="ion-padding" :fullscreen="true">
      {{ data }}
    </ion-content>
  </ion-page>
</template>

<script>
import {
  IonPage,
  IonHeader,
  IonToolbar,
  IonIcon,
  IonContent,
  modalController
} from "@ionic/vue";

import { chevronBackOutline } from "ionicons/icons"

export default {
  name: 'MasterDetail',
  props: {
    data: {
      type: String
    },
  },
  components: {
    IonPage,
    IonHeader,
    IonToolbar,
    IonIcon,
    IonContent,
  },
  setup() {
    return {
      chevronBackOutline
    }
  },
  methods: {
    closeForm() {
      // this.$ionic.modalController.dismiss();
      modalController.dismiss();
    }
  }
}
</script>

<style scoped>
</style>

Here is a demo and repo with the full example.

2 Likes

OMG! Thank you all very much, it’s really amazing to see how collaborative you are and how many different ways of doing things there are.

I tried both @treigh and @dlodeprojuicer solutions and they gave me an idea, which is similar to the approach suggested by @dlodeprojuicer.

As I wanted my details pane to be very dynamic, and I wanted to be able to show and collapse it whenever I wanted, I simply inserted a div and “played” with its position:

Home.vue

<template>
  <ion-page>
    <ion-content
      class="HomePageWithDetailsPane"
      :class="{
        'HomePageWithDetailsPane--withDetailsPaneOpen': detailsPaneOpened,
      }"
    >
    <div>
     <!-- MAIN CONTENT HERE -->
    </div>

    <!-- Details Pane -->
    <div class="HomePageWithDetailsPane-detailsPane ">
        <div
          class="SingleTaskPaneSpreadsheet"
          tabindex="-1"
        >
          <ion-buttons
            id="closeDetailsPaneButton"
          >
            <ion-button
              slot="icon-only"
              fill="clear"
              class="d-flex align-items-center"
              @click="closeDetailsPane()"
            >
              <ion-icon
                src="../../assets/icon/collapseright.svg"
              />
            </ion-button>
          </ion-buttons>
          <div>
            <ion-router-outlet
              :key="$route.fullPath"
              @cancel="closeDetailsPane()"
            />
          </div>
        </div>
      </div>
    </ion-content>
  </ion-page>
</template>

styles.css

.HomePageWithDetailsPane {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  min-height: 1px;
  position: relative;
}

.HomePageWithDetailsPane--withDetailsPaneOpen
.HomePageWithDetailsPane-detailsPane {
  transform: translateX(-100%);
  transition: .5s cubic-bezier(0.23, 1, 0.32, 1);
}

.HomePageWithDetailsPane-detailsPane {
  background-color: #fff;
  border-left: 1px solid #e8ecee;
  border-top: 1px solid #e8ecee;
  bottom: 0;
  box-shadow: 0 5px 20px 0 rgb(21 27 38 / 8%);
  display: flex;
  flex-direction: column;
  left: 100%;
  position: absolute;
  top: 0;
  transition: .2s ease-in;
  width: 40%;
  z-index: 1050;
}

@media (max-width: 800px) {
  .HomePageWithDetailsPane-detailsPane {
    width: 100%;
  }
}

@media (min-width: 900px) and (max-width: 1000px) {
  .HomePageWithDetailsPane-detailsPane {
    width: 45%;
  }
}

@media (min-width: 800px) and (max-width: 900px) {
  .HomePageWithDetailsPane-detailsPane {
    width: 50%;
  }
}

Thus, changing the value of the attribute detailsPaneOpened, and depending on the width of the screen, you will get the details pane opened (to the right or in the “main” view) or closed.

As I said, thank you for your answers, and sorry I couldn’t answer you sooner! Let me know if you have additional comments or suggestions.

Big hug!