Custom Search UI
Showcases how to build a custom search view on top of the search API.
/* * Copyright © 2016-2026 PSPDFKit GmbH. All rights reserved. * * The PSPDFKit Sample applications are licensed with a modified BSD license. * Please see License for details. This notice may not be removed from this file. */
package com.pspdfkit.catalog.examples.java;
import static com.pspdfkit.catalog.SdkExample.TAG;import static com.pspdfkit.catalog.tasks.ExtractAssetTask.extract;
import android.animation.Animator;import android.animation.AnimatorListenerAdapter;import android.annotation.SuppressLint;import android.content.Context;import android.content.Intent;import android.graphics.Color;import android.graphics.Point;import android.graphics.Typeface;import android.graphics.drawable.Drawable;import android.net.Uri;import android.os.Bundle;import android.text.Html;import android.text.SpannableString;import android.text.Spanned;import android.text.style.BackgroundColorSpan;import android.text.style.StyleSpan;import android.util.Log;import android.view.LayoutInflater;import android.view.Menu;import android.view.MenuItem;import android.view.View;import android.view.ViewAnimationUtils;import android.view.ViewGroup;import android.view.animation.AccelerateInterpolator;import android.widget.AbsListView;import android.widget.AdapterView;import android.widget.BaseAdapter;import android.widget.Button;import android.widget.ImageView;import android.widget.ListView;import android.widget.TextView;import androidx.annotation.IntRange;import androidx.annotation.NonNull;import androidx.annotation.Nullable;import androidx.annotation.UiThread;import androidx.appcompat.app.AlertDialog;import androidx.appcompat.app.AppCompatActivity;import androidx.appcompat.widget.SearchView;import androidx.appcompat.widget.Toolbar;import androidx.core.graphics.drawable.DrawableCompat;import com.pspdfkit.catalog.R;import com.pspdfkit.catalog.SdkExample;import com.pspdfkit.catalog.utils.OnScrollListenerAdapter;import com.pspdfkit.catalog.utils.Utils;import com.pspdfkit.configuration.PdfConfiguration;import com.pspdfkit.configuration.activity.PdfActivityConfiguration;import com.pspdfkit.document.PdfDocument;import com.pspdfkit.document.search.SearchOptions;import com.pspdfkit.document.search.SearchResult;import com.pspdfkit.document.search.TextSearch;import com.pspdfkit.listeners.DocumentListener;import com.pspdfkit.ui.PdfFragment;import com.pspdfkit.ui.search.PdfSearchViewInline;import com.pspdfkit.ui.search.SearchResultHighlighter;import com.pspdfkit.utils.Size;import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;import io.reactivex.rxjava3.core.Flowable;import io.reactivex.rxjava3.core.Observable;import io.reactivex.rxjava3.core.ObservableSource;import io.reactivex.rxjava3.disposables.Disposable;import io.reactivex.rxjava3.functions.Supplier;import io.reactivex.rxjava3.schedulers.Schedulers;import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Locale;import java.util.Map;
/** * This example takes the {@link PdfSearchViewInline} and places it inside a custom layout. To do so * it uses a custom activity {@link CustomSearchUiActivity} which will manage creation of the new * layout as well as showing and hiding of the inline search. */public class CustomSearchUiExample extends SdkExample {
public CustomSearchUiExample(@NonNull final Context context) { super(context, R.string.customSearchUiExampleTitle, R.string.customSearchUiExampleDescription); }
@Override public void launchExample( @NonNull final Context context, @NonNull final PdfActivityConfiguration.Builder configuration) { // Load the example document from the assets and launch the activity. extract(WELCOME_DOC, getTitle(), context, documentFile -> { final Intent intent = new Intent(context, CustomSearchUiActivity.class); intent.putExtra(CustomSearchUiActivity.EXTRA_URI, Uri.fromFile(documentFile)); context.startActivity(intent); }); }
/** * This activity is a completely custom activity, using the {@link PdfFragment} for displaying a * document. Furthermore, it uses the support {@link SearchView} to ask the user for a search term, * and the {@link TextSearch} to search the loaded document for results. Finally, the {@link * SearchResultHighlighter} is used to draw the results on top of the pages. */ public static class CustomSearchUiActivity extends AppCompatActivity implements AdapterView.OnItemClickListener {
public static final String EXTRA_URI = "CustomSearchUiExample.DocumentUri"; private static final PdfConfiguration config = new PdfConfiguration.Builder().build();
private PdfFragment fragment; private PdfDocument document;
@Nullable private TextSearch textSearch;
@Nullable private Disposable currentSearch;
private SearchResultHighlighter highlighter;
private SearchResultAdapter adapter; private List<SearchResult> currentSearchResults; private int selectedSearchResult;
private View listViewContainer; private View searchResultNavigationContainer; private TextView currentSearchResultTextView; private Button nextResultButton; private Button previousResultButton;
private MenuItem searchAction; private SearchOptions searchOptions;
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_custom_search_ui);
// The actual document Uri is provided with the launching intent. You can simply change that // inside the CustomSearchUiExample class. // This is a check that the example is not accidentally launched without a document Uri. final Uri uri = getIntent().getParcelableExtra(EXTRA_URI); if (uri == null) { showCouldNotStartExample("No document Uri was provided with the launching intent."); return; }
// Extract all views from the root layout. final Toolbar toolbar = findViewById(R.id.toolbar); listViewContainer = findViewById(R.id.searchResultsListContainer); final ListView listView = findViewById(R.id.searchResultsListView); searchResultNavigationContainer = findViewById(R.id.searchResultNavigationContainer); currentSearchResultTextView = findViewById(R.id.currentSearchResultTextView); nextResultButton = findViewById(R.id.nextSearchResultButton); previousResultButton = findViewById(R.id.previousSearchResultButton);
if (toolbar == null || listView == null || nextResultButton == null || previousResultButton == null) { showCouldNotStartExample("Some of the required views were not inflated."); return; }
setSupportActionBar(toolbar);
nextResultButton.setOnClickListener(v -> selectSearchResultAtIndex(selectedSearchResult + 1)); previousResultButton.setOnClickListener(v -> selectSearchResultAtIndex(selectedSearchResult - 1));
// Extract the existing fragment from the layout, or create a new fragment instance if none // has been attached yet. fragment = (PdfFragment) getSupportFragmentManager().findFragmentById(R.id.fragmentContainer); if (fragment == null) { fragment = PdfFragment.newInstance(uri, config); getSupportFragmentManager() .beginTransaction() .add(R.id.fragmentContainer, fragment) .commit(); }
// Register a listener to retrieve the document as soon as it was loaded, or show an dialog // if an error occurred. fragment.addDocumentListener(new DocumentListener() { @UiThread @Override public void onDocumentLoaded(@NonNull PdfDocument loadedDocument) { document = loadedDocument; textSearch = new TextSearch(loadedDocument, config); prepareSearchSplash(findViewById(R.id.empty)); }
@Override public void onDocumentLoadFailed(@NonNull Throwable exception) { Log.e(TAG, "Error while loading the document.", exception); showCouldNotStartExample( "An exception was thrown while loading the document. See logcat for details."); } });
// To show search results on top of the document, a highlighter is used. This class is a // PdfDrawableProvider // and is registered as such on the fragment. We will set search results on the highlighter, // once available. highlighter = new SearchResultHighlighter(this); fragment.addDrawableProvider(highlighter);
// Prepare the ListView and SearchResultAdapter which is used to display search results. To // make better use of // the available screen estate, collapse the soft-keyboard as soon as the user scrolls the // list. adapter = new SearchResultAdapter(this); listView.setAdapter(adapter); listView.setOnItemClickListener(this); listView.setOnScrollListener(new OnScrollListenerAdapter() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { if (scrollState != SCROLL_STATE_IDLE) { hideSoftKeyboard(); } } });
searchOptions = new SearchOptions.Builder().snippetLength(40).build(); }
private void showCouldNotStartExample(String message) { new AlertDialog.Builder(this) .setTitle("Could not start example.") .setMessage(message) .setNegativeButton("Leave example", (dialog, which) -> dialog.dismiss()) .setOnDismissListener(dialog -> finish()) .show(); }
@Override public boolean onCreateOptionsMenu(Menu menu) { super.onCreateOptionsMenu(menu);
// Inflate the action bar menu that will add a search action. getMenuInflater().inflate(R.menu.activity_custom_search_view, menu); searchAction = menu.findItem(R.id.search);
final Drawable searchIcon = DrawableCompat.wrap(searchAction.getIcon()); DrawableCompat.setTint(searchIcon, Color.WHITE); searchAction.setIcon(searchIcon);
final SearchView searchView = (SearchView) searchAction.getActionView(); searchView.setIconifiedByDefault(false); searchView.requestFocus(); searchView.setQueryHint("Search PDF document...");
searchAction.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { @Override public boolean onMenuItemActionExpand(MenuItem item) { return true; }
@Override public boolean onMenuItemActionCollapse(MenuItem item) { clearCurrentSearchResults(); hideSearchResultsList(); hideSoftKeyboard(); return true; } });
searchView.setOnQueryTextFocusChangeListener((v, hasFocus) -> { if (hasFocus) { showSearchResultList(); } });
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { return false; }
@Override public boolean onQueryTextChange(String newText) { if (newText.length() > 2 && textSearch != null) { if (currentSearch != null) { currentSearch.dispose(); }
currentSearch = textSearch .performSearchAsync(newText, searchOptions) .onErrorResumeNext(throwable -> Flowable.empty()) .toList() .observeOn(AndroidSchedulers.mainThread()) .subscribe(searchResults -> { adapter.setSearchResults(searchResults);
final View emptyView = findViewById(R.id.empty); if (emptyView != null && emptyView.getVisibility() != View.INVISIBLE) { emptyView.setAlpha(1); emptyView .animate() .alpha(0) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { emptyView.animate().setListener(null); emptyView.setVisibility(View.INVISIBLE); } }) .start(); } }); } else { final View emptyView = findViewById(R.id.empty); if (emptyView != null && emptyView.getVisibility() != View.VISIBLE) { emptyView.setAlpha(0); emptyView.setVisibility(View.VISIBLE); emptyView .animate() .alpha(1) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { emptyView.animate().setListener(null); adapter.setSearchResults(null); } }) .start(); } }
return true; } });
return true; }
@Override public boolean onOptionsItemSelected(MenuItem item) { boolean handled = false;
if (item.getItemId() == R.id.search) { item.expandActionView(); showSearchResultList(); handled = true; }
return handled || super.onOptionsItemSelected(item); }
/** Called when the user selects a page from the search results list. */ @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { hideSoftKeyboard();
//noinspection unchecked currentSearchResults = (List<SearchResult>) parent.getAdapter().getItem(position); selectedSearchResult = 0; updateSearchResultNavigationBar();
highlighter.setSearchResults(currentSearchResults); highlighter.setSelectedSearchResult(currentSearchResults.get(0)); fragment.setPageIndex(currentSearchResults.get(0).pageIndex, false);
hideSearchResultsList(); searchResultNavigationContainer.setVisibility(View.VISIBLE);
final View contentView = findViewById(android.R.id.content); assert contentView != null; contentView.requestFocus(); }
private void hideSoftKeyboard() { final View focusedView = getCurrentFocus(); if (focusedView != null) { Utils.hideKeyboard(focusedView); } }
/** Create some fancy looking document statistics as part of the search list's empty view. */ @SuppressLint("CheckResult") private void prepareSearchSplash(@Nullable final TextView splashTextView) { if (splashTextView == null) return;
Observable.defer((Supplier<ObservableSource<String>>) () -> { final int pageCount = document.getPageCount(); int wordCount = 0;
for (int i = 0; i < pageCount; i++) { final String pageText = document.getPageText(i); wordCount += pageText.split("\\w").length; }
return Observable.just(getString(R.string.custom_search_ui_splash, pageCount, wordCount)); }) .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(s -> { Spanned result = Html.fromHtml(s, 0); splashTextView.setText(result); }); }
/** Marks the search result at {@code searchResultIndex} as selected. */ private void selectSearchResultAtIndex(@IntRange(from = 0) final int searchResultIndex) { if (searchResultIndex < 0 || searchResultIndex >= currentSearchResults.size()) { return; }
if (selectedSearchResult != searchResultIndex) { selectedSearchResult = searchResultIndex; updateSearchResultNavigationBar(); highlighter.setSelectedSearchResult(currentSearchResults.get(selectedSearchResult)); }
final SearchResult result = currentSearchResults.get(selectedSearchResult); if (fragment.getPageIndex() != result.pageIndex) { fragment.setPageIndex(result.pageIndex); } }
/** Update buttons and text of the on-page search result navigation. */ private void updateSearchResultNavigationBar() { currentSearchResultTextView.setText(getString( R.string.currently_selected_result, selectedSearchResult + 1, currentSearchResults.size())); previousResultButton.setEnabled(selectedSearchResult > 0); nextResultButton.setEnabled(selectedSearchResult < currentSearchResults.size() - 1); }
/** Clear all search results both visually as well as all data. */ private void clearCurrentSearchResults() { highlighter.clearSearchResults();
if (searchResultNavigationContainer.getVisibility() != View.INVISIBLE) { searchResultNavigationContainer .animate() .translationY(-searchResultNavigationContainer.getHeight()) .setInterpolator(new AccelerateInterpolator(1.5f)) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(final Animator animation) { searchResultNavigationContainer.animate().setListener(null); searchResultNavigationContainer.setTranslationY(0); searchResultNavigationContainer.setVisibility(View.INVISIBLE); } }) .start(); } }
private void showSearchResultList() { if (listViewContainer.getVisibility() != View.VISIBLE) { searchAction.setEnabled(false); listViewContainer.setVisibility(View.VISIBLE); createRevealAnimation(true).start(); } }
private void hideSearchResultsList() { if (listViewContainer.getVisibility() != View.INVISIBLE) { final Animator hideAnimator = createRevealAnimation(false); hideAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { animation.removeListener(this); listViewContainer.setVisibility(View.INVISIBLE); searchAction.setEnabled(true); } });
hideAnimator.start(); } }
private Animator createRevealAnimation(boolean showReveal) { final Point screenSize = new Point(); getWindowManager().getDefaultDisplay().getSize(screenSize); final float screenDiameter = (float) Math.sqrt(screenSize.x * screenSize.x + screenSize.y * screenSize.y);
final float startRadius, endRadius; if (showReveal) { startRadius = 0; endRadius = screenDiameter; } else { startRadius = screenDiameter; endRadius = 0; }
// Reveal is centered right below the action bar. return ViewAnimationUtils.createCircularReveal( listViewContainer, screenSize.x / 2, 0, startRadius, endRadius); }
private static class ViewHolder { @NonNull public static ViewHolder get(View view, ViewGroup parent) { ViewHolder holder;
if (view != null) { holder = (ViewHolder) view.getTag(); } else { view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.activity_custom_search_ui_item, parent, false); holder = new ViewHolder(view); view.setTag(holder); }
return holder; }
public final View view; public final ImageView pagePreviewImageView; public final TextView searchResultsCountView; public final TextView pageNumberTextView; public final TextView previewTextView; public Disposable previewRenderSubscription;
private ViewHolder(View view) { this.view = view; pagePreviewImageView = view.findViewById(R.id.pagePreviewImageView); searchResultsCountView = view.findViewById(R.id.searchResultsCountView); pageNumberTextView = view.findViewById(R.id.pageNumberTextView); previewTextView = view.findViewById(R.id.previewTextView); } }
/** * List view adapter for presenting search results, grouped by page (that is one list item per * page with results). */ private class SearchResultAdapter extends BaseAdapter {
private final int previewImageWidth; /** List of all search results, grouped by page. */ @Nullable private List<List<SearchResult>> searchResults;
private SearchResultAdapter(@NonNull final Context context) { previewImageWidth = context.getResources().getDimensionPixelSize(R.dimen.custom_search_ui_previewimage_width); }
public void setSearchResults(@Nullable final List<SearchResult> searchResults) { if (searchResults == null) { this.searchResults = null; } else { // Group all search results by page. final Map<Integer, List<SearchResult>> groupedSearchResults = new HashMap<>(); for (SearchResult result : searchResults) { if (groupedSearchResults.get(result.pageIndex) == null) { groupedSearchResults.put(result.pageIndex, new ArrayList<>()); } groupedSearchResults.get(result.pageIndex).add(result); }
// Store the groups inside a list, to make them accessible using adapter position. this.searchResults = new ArrayList<>(); for (List<SearchResult> resultsOnPage : groupedSearchResults.values()) { this.searchResults.add(resultsOnPage); } }
notifyDataSetChanged(); }
@Override public int getCount() { return searchResults == null ? 0 : searchResults.size(); }
@Override public List<SearchResult> getItem(int position) { return searchResults == null ? null : searchResults.get(position); }
@Override public long getItemId(int position) { return position; }
@Override public View getView(int position, View convertView, ViewGroup parent) { final ViewHolder holder = ViewHolder.get(convertView, parent); final List<SearchResult> item = getItem(position); final SearchResult displayedResult = item.get(0); final int resultsCount = item.size(); assert displayedResult.snippet != null;
if (holder.previewRenderSubscription != null) { holder.previewRenderSubscription.dispose(); }
// Calculate the size of the rendered preview image. final int width = previewImageWidth; final int height = calculateBitmapHeight(width, displayedResult.pageIndex); holder.previewRenderSubscription = document.renderPageToBitmapAsync( parent.getContext(), displayedResult.pageIndex, width, height) .observeOn(AndroidSchedulers.mainThread()) .subscribe(holder.pagePreviewImageView::setImageBitmap);
holder.pageNumberTextView.setText( String.format(Locale.getDefault(), "Page %d", displayedResult.pageIndex + 1)); holder.searchResultsCountView.setText( getResources().getQuantityString(R.plurals.search_results, resultsCount, resultsCount));
// Highlight the actual search results phrase. final SpannableString previewText = new SpannableString(displayedResult.snippet.text); previewText.setSpan( new StyleSpan(Typeface.BOLD), displayedResult.snippet.rangeInSnippet.getStartPosition(), displayedResult.snippet.rangeInSnippet.getEndPosition(), 0); previewText.setSpan( new BackgroundColorSpan(Color.YELLOW), displayedResult.snippet.rangeInSnippet.getStartPosition(), displayedResult.snippet.rangeInSnippet.getEndPosition(), 0); holder.previewTextView.setText(previewText);
return holder.view; }
private int calculateBitmapHeight(final int width, @IntRange(from = 0) final int pageIndex) { final Size size = document.getPageSize(pageIndex); return (int) (size.height * (width / size.width)); } } }}This code sample is an example that illustrates how to use our SDK. Please adapt it to your specific use case.