Displaying Pagination Results with a Skip Amount - An Example of Thinking Differently when Coding

July 18th, 2018
Twitter Facebook Google+

For a project I was working on over the past few days, one of the requirements was paginating a result set and displaying it in an easily traversable way.

This project lists the email addresses of companies from around the web, and you can browse the list by the beginning letter of the company domain names. As a result, there can be thousands of pages for a given letter or number.

The goal was to show the closest two surrounding pages relative to the current page the user was on, and then start skipping pages by a set amount. Once the final few pages are being approached, it should stop skipping and output the remaining pages. For instance, the pagination links for 37 pages should look something like this:

1  2  3  ...  10  ...  20 ... 30 ... 35 36 37

Now, if the user is on, let's say, the 19th page, then it should display like this:

1 2 3 ... 10 ... 17 18 19 20 21 ... 30 ... 35 36 37

Here's an example of the final solution in use - browse emails of companies by the letter A

Seems like a fairly tough problem at first glance, right? Well, it definitely was for me, until I tried thinking 'outside the box' with different ways of arriving at a solution. While this blog post provides an algorithm to produce paginated results like this, the main point is show the thought process behind the solution.

First Try - Brute Forcing Logic

At first, I was thinking of a more direct path to a solution.

We can start by declaring a function that accepts two arguments. The first argument will be the current page the user is on, and the second argument will be the total number of pages. Returned will be the final pagination string:

package main

import "fmt"

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	return "1  2  3"
}

func main() {
	pages := getPages(1, 37)
	fmt.Println(pages)
}
https://play.golang.org/p/sGsRdgS4VNN

The next thing we will definitely need in this function is a loop. If we were simply required to output all of the pages without any sort of skipping, then this loop would basically be the only part of our function:

package main

import (
	"fmt"
	"strconv"
)

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	var result string

	// Start the loop.
	for i := 1; i <= totalPages; i++ {
		// If this is the first iteration.
		if i == 1 {
			result = "1"
			continue
		}

		result += "  " + strconv.Itoa(i)
	}

	return result
}

func main() {
	pages := getPages(1, 37)
	fmt.Println(pages)
}
https://play.golang.org/p/APYlGdPD07R

Running the above program will produce the following output:

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

Unfortunately, it won't be that easy for us. Thinking of the next step, it may become apparent to you that regardless of what page we're on, we'll always be outputting the first three pages. So, let's go ahead and remove the full output, and add this new logic to our function:

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	var result string

	// Start the loop.
	for i := 1; i <= totalPages; i++ {
		// If this is the first iteration.
		if i == 1 {
			result = "1"
			continue
		}

		// Handle first three pages.
		if i <= 3 {
			result += "  " + strconv.Itoa(i)
			continue
		}
	}

	return result
}
https://play.golang.org/p/281ELTe75PH

And running this program, we'll see this output:

1  2  3

Once we get past the third iteration, what should we do next? Well, we want to either display an ellipsis (...), or the page number if we're at a multiple of our skip amount (i.e. 10, 20, 30, etc). To determine if we're at a skip number, we can use a modulo operation and check if the result is 0. Modulo returns the remainder of division between two numbers, so if we modulo the current loop iteration by the skip amount and the result is 0, we know that number is divisible by our skip amount. Let's add this logic to our function:

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	var result string

	// Start the loop.
	for i := 1; i <= totalPages; i++ {
		// If this is the first iteration.
		if i == 1 {
			result = "1"
			continue
		}

		// Handle first three pages.
		if i <= 3 {
			result += "  " + strconv.Itoa(i)
			continue
		}

		// If the current iter is divisble by skip amount.
		if i % 10 == 0 {
			result += "  " + strconv.Itoa(i)
			continue
		}
	}

	return result
}
https://play.golang.org/p/uLpLw6rmBWm

Now running this code, we should see the following output:

1  2  3  10  20  30

Two things left missing - the ellipsis, and the final pages. To decide when to output an ellipsis, we can just add that logic in with the skip amount. For outputting the final pages, we can mimic the same check for the first three pages:

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	var result string

	// Start the loop.
	for i := 1; i <= totalPages; i++ {
		// If this is the first iteration.
		if i == 1 {
			result = "1"
			continue
		}

		// Handle first three pages.
		if i <= 3 {
			result += "  " + strconv.Itoa(i)
			continue
		}

		// If the current iter is divisble by skip amount.
		if i % 10 == 0 {
			result += "  ...  " + strconv.Itoa(i)
			continue
		}

		// If we're within 3 iterations of the last page.
		if i + 2 >= totalPages {
			if i+2 == totalPages {
				result += "  ..."
			}

			result += "  " + strconv.Itoa(i)
		}
	}

	return result
}
https://play.golang.org/p/Ytn8z2T9SSD

And we should see the following output:

1  2  3  ...  10  ...  20  ...  30  ...  35  36  37

Awesome! This is outputting exactly how we expect it to, but... there's some major problems with the current algorithm. At the moment, it will only handle the current page being set to 1. Let's change the current page argument to 2 and see what we get:

https://play.golang.org/p/rwzmf7CtRJt
1  2  3  ...  10  ...  20  ...  30  ...  35  36  37

Notice how the 4th page isn't displaying, even though we're on page 2, and we want to show the closest two surrounding pages relative to the current page? We get the same result as if the current page was set to 1, which is expected, because we haven't implemented any logic to handle other values for the current page.

I started to realize that this approach may not be the most suitable way to solve this problem. While I probably could brute force the math logic involved, handling specific use cases, and ending up with a behemoth of an algorithm, I wanted to see if there was a more sane way of approaching this.

Second Try - Trying to Think like AI

It was at this point I decided to take a break, and I mentioned to a friend how cool it would be to have some form of artificial intelligence that could write functions like this for you just by giving it the input parameters and the expected output.

As a human being with years of experience in reading, writing, and what looks aesthetically pleasing to other humans, it's very easy for us to understand what the current page is, what the total number of pages are, and how the final resulting pagination string should look based on these factors. Trying to program this logic though, as you can see, is a different story. What if we could approach this problem closer to how our brains and AI naturally work?

I started to think of the problem in a more visual sense. As a human, given this problem to solve, we wouldn't be using our brains to loop through the number 1 up to the total number of pages, checking if the current iteration value is the current page, etc etc. Instead, we would probably handle the problem much faster and easier visually.

We know that the first three pages should always be displayed, that's super easy. We know that the previous two pages and following two pages next to the current page should also always be displayed - easy. We know that the last three pages should always be displayed, so that's easy, too. Finally, we know that in between these values, we should skip the page count by a certain amount while showing an ellipsis in between.

Let's recreate our function, and start with always displaying the first three pages:

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	var result string

	// Start the loop.
	for i := 1; i <= totalPages; i++ {
		// If this is the first iteration.
		if i == 1 {
			result = "1"
			continue
		}

		// Handle first three pages.
		if i <= 3 {
			result += "  " + strconv.Itoa(i)
			continue
		}
	}

	return result
}
https://play.golang.org/p/281ELTe75PH

And we get the following output again:

1  2  3

Let's add in the surrounding pages logic next. What we can do is create a map that stores the page number as its index and a boolean as its value. If we check the map for the given page number and it returns true, we will know that it is a surrounding page:

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	var result string

	// Create a map that holds the previous
	// and following two page numbers.
	surroundingPages := map[int]bool{}
	for i := curPage; i > 0; i-- {
		// Break if we're at 2 entries added.
		if curPage-i > 2 {
			break
		}

		surroundingPages[i] = true
	}

	for i := curPage; i <= totalPages; i++ {
		// Break if we're at 2 entries added.
		if i-curPage > 2 {
			break
		}

		surroundingPages[i] = true
	}

	// Start the loop.
	for i := 1; i <= totalPages; i++ {
		// If this is the first iteration.
		if i == 1 {
			result = "1"
			continue
		}

		// Handle first three pages.
		if i <= 3 {
			result += "  " + strconv.Itoa(i)
			continue
		}

		// Handle surrounding pages.
		if surroundingPages[i] {
			result += "  " + strconv.Itoa(i)
			continue
		}
	}

	return result
}
https://play.golang.org/p/H0K5HEV3Nzo

And we should see this as the output:

1  2  3

Same as before, which is expected. Let's change the current page argument though of the getPages function to 2 instead of 1, and see what we get:

https://play.golang.org/p/mLyzXwqgiCW
1  2  3  4

Awesome, it's showing the first three pages as it always should, and because the current page is set to 2, it's showing the next two pages next to it, pages 3 and 4.

Now, if we take a step back and think about it, we now have all the info we need to know exactly which pages should always be displayed in the pagination string. The first three pages, the current page and closest two surrounding pages, the skip amounts, and finally the last three pages. The only thing left to determine is how to handle placing this ellipsis... and here's the cool part - this, too, can be handled 'visually'.

For deciding when and where to place an ellipsis, we can simply store what 'type' of value was last appended to the pagination string.

If the iterator is not currently on one of the pages that should be displayed in the pagination string, then it should just continue iterating until it reaches one of those pages. We know in between each of these sections, the ellipsis should be shown. So, what we can do is store the 'type' of value that was last appended to the pagination string. If we reach the end of our loop, meaning the current iteration didn't match any of the page numbers that should be displayed, then we're 'in between' pages and should either output and ellipsis, or just continue iterating. By storing the last type of value appended, either a 'number', or an 'ellipsis', then we can use that 'visual' cue to determine what to do.

Let's tie all of this logic together - the first three pages, the current and surrounding pages, the skip amount, the last three pages, and the ellipsis - and build out our final algorithm:

package main

import (
	"fmt"
	"strconv"
)

// getPages handles creating the formatted pagination string.
func getPages(curPage, totalPages int) string {
	var result string

	// Create a map that holds the previous
	// and following two page numbers.
	surroundingPages := map[int]bool{}
	for i := curPage; i > 0; i-- {
		// Break if we're at 2 entries added.
		if curPage-i > 2 {
			break
		}

		surroundingPages[i] = true
	}

	for i := curPage; i <= totalPages; i++ {
		// Break if we're at 2 entries added.
		if i-curPage > 2 {
			break
		}

		surroundingPages[i] = true
	}

	// Create variable to store the last string
	// type added, so we can add an ellipsis
	// when needed.
	var lastStringType string

	// Start the loop.
	for i := 1; i <= totalPages; i++ {
		// If this is the first iteration.
		if i == 1 {
			result = "1"
			lastStringType = "number"
			continue
		}

		// Handle first three pages.
		if i <= 3 {
			result += "  " + strconv.Itoa(i)
			lastStringType = "number"
			continue
		}

		// Handle surrounding pages.
		if surroundingPages[i] {
			result += "  " + strconv.Itoa(i)
			lastStringType = "number"
			continue
		}

		// If the current iter is divisble by skip amount.
		if i%10 == 0 {
			result += "  " + strconv.Itoa(i)
			lastStringType = "number"
			continue
		}

		// If we're within 3 iterations of the last page.
		if i+2 >= totalPages {
			result += "  " + strconv.Itoa(i)
			lastStringType = "number"
			continue
		}

		// If we got here, we're 'ghosting' in between
		// the pages or skip amounts, so let's se if we
		// should output an ellipsis.
		if lastStringType == "number" {
			result += "  ..."
			lastStringType = "ellipsis"
			continue
		}
	}

	return result
}

func main() {
	pages := getPages(1, 37)
	fmt.Println(pages)
}
https://play.golang.org/p/2Ej4yIiUZzG

Which should produce the final desired output, ellipsis, skip amounts and all:

1  2  3  ...  10  ...  20  ...  30  ...  35  36  37

Try changing the current page argument to 19, as in our first example, and let's see what we get:

1  2  3  ...  10  ...  17  18  19  20  21  ...  30  ...  35  36  37

Awesome!

If you want to signify the current page (i.e. make the number bold), then you can create a function that determines how to append the next page number (instead of just doing result += ...). This function can check if the current iteration value is equal to the current page, and if so, handle it however you like.

I hope this helps some of you reading, not only if you need a pagination algorithm that matches this sort of output, but also if you ever encounter a seemingly tough issue to try and think about different solutions. There's usually multiple paths to an answer, some better than others, and by taking a step back and considering other possibilities, you may just find the ideal one.

Comments