Charles-android multimedia selector

introduce

Many functions of current APPS are related to multimedia, such as pictures, videos, audio, files and so on. In Android development, if we need to select multimedia files locally, we can call the DocumentsUI of the system to achieve this, of course, there are often compatibility and UI style problems in this way. We can implement a multimedia selector ourselves. Let’s start with what Charles looks like:

Charles Style Charles Dark Style Empty View

Simple to use

Let’s look at how Charles should be used, starting with Charles:

Charles.from(this@MainActivity)
        .choose()
        .maxSelectable(9)
        .progressRate(true)
        .theme(R.style.Charles)
        .imageEngine(GlideEngine())
        .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
        .forResult(REQUEST_CODE_CHOOSE)
Copy the code

Received return result:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CODE_CHOOSE && resultCode == Activity.RESULT_OK) {
        val uris = Charles.obtainResult(data)
        val paths = Charles.obtainPathResult(data)
        mAdapter.setData(uris, paths)
        Log.d("uris", "$uris")
        Log.d("paths", "$paths")
    }
}
Copy the code

Is it easy?

features

As you can see from the code and the screenshot above, Charles supports themes. Charles is more than that. With Charles, you can:

  • Directly in theActivityorFragmentIn the call;
  • Select multiple media files, including pictures, video, audio and files;
  • Charles has two built-in themes: Daytime mode (Charles) and Night mode (CharlesDark). If these two sets of themes don’t fit your needs, you can still customize the theme;
  • Specifies the maximum number of options;
  • Supports vertical and horizontal screens. Charles implements internal state saving, so changes in the Configuration will not adversely affect Charles.
  • Support a variety of picture loading library. Charles internally offers two image-loading engines: Glide and Picasso. Of course, if neither of these is what you need, you can also customize the image loading engine by implementing an interface. Charles does not currently support Fresco.

Inside Charles, Loader is used as the Loader of multimedia files. If you are interested in Loader, you can refer to the official documentation (developer.android.com/guide/compo…). Charles does not use MVP or MVVM and other MV* to achieve decoupling internally, and does not use too many third-party libraries (in fact, only rely on Kotlin, RecyclerView, AppCompat and a few other necessary libraries).

Use the advanced

Graphics engine

Built-in image engine

Charles has two built-in image-loading engines: GlideEngine and PicassoEngine. The image engine can be used in the following ways:

Charles.from(this@MainActivity) ... .imageEngine(GlideEngine()) // PicassoEngine()... .forResult(REQUEST_CODE_CHOOSE)Copy the code

Custom image engine

Through the implementation of ImageEngine interface to achieve a custom image loading engine. For example, if you want to use the Generated API for Glide V4, you can define the following classes:

class GlideLoader : ImageEngine {

    override fun loadImage(context: Context, imageView: ImageView, uri: Uri) {
        GlideApp.with(context)
                .load(uri)
                .centerCrop()
                .into(imageView)
    }

}
Copy the code

The theme

Built-in themes

Charles has two built-in themes: R.Style.Charles (Day mode) and R.Style.CharlesDark (Night mode). You can specify the theme(@styleres themeId: Int) method when starting Charles:

Charles.from(this@MainActivity) ... .Theme (R.style.charles) // or R.Style.charles dark... .forResult(REQUEST_CODE_CHOOSE)Copy the code

Custom themes

You can customize the look of Charles with two built-in themes and even their parent themes. Here are the attributes you can modify (defined in the attrs.xml file):

  • colorPrimary: Branding color of APP Bar
  • colorPrimaryDark: Color of the status bar
  • toolbarTheme: the toolbar
  • toolbar.titleColor: Toolbar title color of the selected category
  • categories.dropdown.titleColor: Title color of the category in the optional category drop-down list
  • categories.dropdown.icon.tint: Color of the category icon in the optional category drop-down list
  • media.emptyView: Image of the hollow view of the file list
  • media.emptyView.textColor: Text color for the hollow view of the file list
  • media.checkView.iconColor: The color of the check box for the selected file
  • media.checkView.backgroundColor: The background color of the selected file view
  • media.checkView.borderColor: The border color of the file selection view
  • media.selected.backgroundColor: The background color of the selected file
  • media.titleColor: Title color of the file
  • media.descColor: Specifies the color of the description text
  • page.backgroundColor: ActivityFragmentThe background color of the page
  • bottomToolbar.backgroundColor: Background color of the bottom toolbar
  • bottomToolbar.progress.textColor: Text color for the rate of progress in the bottom toolbar
  • bottomToolbar.apply.textColor: Applies the text color of the button on the bottom toolbar
  • listPopupWindowStyle: Subject of the optional category drop-down list

count

Maximum number of options

MaxSelectable (maxSelectable: Int) limits the maximum number of files that can be selected.

The selected schedule

Use progressRate(toShow: Boolean) to decide whether to display the current progressRate.

The screen

Use the restrictOrientation(orientation: Int) Settings file to select the screen orientation for your Activity. All the proposed values can be in android. Content. PM. ActivityInfo found in class.

Source code analysis

Charles’ overall project structure:

Charles & SelectionCreator

Let’s start with the Charles class and SelectionCreator class:

class Charles { // ... private constructor(fragment: Fragment) : this(fragment.activity, fragment) // other constructor and more... companion object { @JvmStatic fun from(activity: Activity) = Charles(activity) @JvmStatic fun from(fragment: Fragment) = Charles(fragment) @JvmStatic fun obtainResult(data: Intent?) = data? .getParcelableArrayListExtra<Uri>(CharlesActivity.EXTRA_RESULT_SELECTION) @JvmStatic fun obtainPathResult(data: Intent?) = data? .getStringArrayListExtra(CharlesActivity.EXTRA_RESULT_SELECTION_PATH) } // ... fun choose(): SelectionCreator = SelectionCreator(this) }Copy the code

In the Charles class, there are four static methods, divided by purpose into two types: Charles#obtainResult() and Charles#obtainPathResult() for retrieving results after file selection; The Charles#from() method returns the Charles object for further customization of Charles. The Charles#choose() method creates the SelectionCreator object and returns it. Let’s look at the SelectionCreator class again:

class SelectionCreator(private val mCharles: Charles) {

    private val mSelectionSpec = SelectionSpec.cleanInstance

    // ...

    fun maxSelectable(maxSelectable: Int): SelectionCreator {
        // ...
        mSelectionSpec.maxSelectable = maxSelectable
        return this
    }

    fun progressRate(isShow: Boolean): SelectionCreator {
        mSelectionSpec.isShowProgressRate = isShow
        return this
    }

    fun theme(@StyleRes themeId: Int): SelectionCreator {
        mSelectionSpec.themeId = themeId
        return this
    }

    fun imageEngine(imageEngine: ImageEngine): SelectionCreator {
        mSelectionSpec.imageEngine = imageEngine
        return this
    }

    fun restrictOrientation(screenOrientation: Int): SelectionCreator {
        mSelectionSpec.orientation = screenOrientation
        return this
    }

    /**
     * Start to select media and wait for result.
     *
     * @param requestCode Identity of the request Activity or Fragment.
     */
    fun forResult(requestCode: Int) {
        // ...
    }

}
Copy the code

As you can see, the main purpose of SelectionCreator is to customize Charles to meet various requirements. Customizable items are stored as member variables in the SelectionSpec class, which is not listed here.

CharlesActivity & CharlesFragment

CharlesActivity is primarily used to display UI components, such as a ListPopupWindow that contains options for images, video, audio, etc., and in extreme cases, data recovery is also done there. Let’s focus on CharlesFragment:

class CharlesFragment : Fragment(), LoaderManager.LoaderCallbacks<Cursor> { private var mFilterType: MediaFilterType? = null private lateinit var mMediaItemsAdapter: MediaItemsAdapter private lateinit var mSelectionProvider: SelectionProvider companion object { private const val ARG_TYPE = "ARG_TYPE" @JvmStatic fun newInstance(filterType: MediaFilterType): CharlesFragment { val fragment = CharlesFragment() val bundle = Bundle() bundle.putSerializable(ARG_TYPE, filterType) fragment.arguments = bundle return fragment } } override fun onAttach(context: Context?) { super.onAttach(context) if (context is SelectionProvider) { mSelectionProvider = context } else { throw IllegalStateException("Context must implement SelectionProvider.") } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup? , savedInstanceState: Bundle?) : View? { return inflater.inflate(R.layout.fragment_charles, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) mFilterType = arguments?.getSerializable(ARG_TYPE) as MediaFilterType? initViews() mMediaItemsAdapter.registerOnMediaClickListener(object : MediaItemsAdapter.OnMediaItemClickListener { override fun onItemClick(item: MediaItem, position: Int) { if (activity ! = null && activity is CharlesActivity) { (activity as CharlesActivity).updateBottomToolbar() } } }) activity? .supportLoaderManager? .initLoader(0, null, this) } override fun onDestroyView() { super.onDestroyView() activity? .supportLoaderManager? .destroyLoader(MediaItemLoader.LOADER_ID) } override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> = MediaItemLoader.newInstance(context!! , mFilterType!!) override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) { mMediaItemsAdapter.swapCursor(data) emptyTextView.visibility = if (mMediaItemsAdapter.itemCount == 0) View.VISIBLE else View.GONE } override fun onLoaderReset(loader: Loader<Cursor>) { mMediaItemsAdapter.swapCursor(null) } private fun initViews() { recyclerView.layoutManager = LinearLayoutManager(context) recyclerView.setHasFixedSize(true) mMediaItemsAdapter = MediaItemsAdapter(mFilterType, mSelectionProvider.provideSelectedItemCollection()) recyclerView.adapter = mMediaItemsAdapter } interface SelectionProvider { fun provideSelectedItemCollection(): SelectedItemCollection } }Copy the code

CharlesFragment layout is a pure list layout, RecyclerView is used, of course, the corresponding Adapter will be used, the Adapter code is not posted here. We focus on see CharlesFragment realize LoaderManager. LoaderCallbacks interface. Actually, this interface is related to Loader. Why does Charles choose Loader?

In Android, any time-consuming operations cannot be placed in the UI main thread, so time-consuming operations need to be implemented asynchronously. Similarly, there can be time-consuming operations in contentProviders and asynchronous operations should also be used. The most recommended asynchronous operation after 3.0 is the Loader. It is convenient for us to load data asynchronously in activities and fragments, instead of using threads or asyncTasks. Its advantages are as follows:

  • Provide asynchronous data loading mechanism;
  • Monitor data source changes and update data in real time;
  • Don’t reload data when the Activity configuration changes (such as switching between vertical and horizontal screens).
  • Applies to any Activity and Fragment.

CharlesFragment implements the LoaderManager. LoaderCallbacks interface, can be induced to the change of the data, so as to make corresponding processing.

MediaItemLoader & SelectedItemCollection & MediaItem

class MediaItemLoader private constructor( context: Context, uri: Uri, projection: Array<String>, selection: String, selectionArgs: Array<String>, sortOrder: String ) : CursorLoader(context, uri, projection, selection, selectionArgs, sortOrder) { companion object { const val LOADER_ID = 0 // Images private const val IMAGES_SELECTION = "${MediaStore.Files.FileColumns.MEDIA_TYPE}=? AND ${MediaStore.MediaColumns.SIZE}>0" private val IMAGE_SELECTION_ARGS = arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString()) private const val IMAGES_ORDER_BY = "${MediaStore.Images.Media.DATE_TAKEN} DESC" private val IMAGE_QUERY_URI = MediaStore.Files.getContentUri("external") private val IMAGES_PROJECTION = arrayOf( MediaStore.Files.FileColumns._ID, MediaStore.MediaColumns.TITLE, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.DATE_ADDED, "duration" ) // ... @JvmStatic fun newInstance(context: Context, mediaFilterType: MediaFilterType): MediaItemLoader = when (mediaFilterType) { MediaFilterType.IMAGE -> { MediaItemLoader(context, IMAGE_QUERY_URI, IMAGES_PROJECTION, IMAGES_SELECTION, IMAGE_SELECTION_ARGS, IMAGES_ORDER_BY) } // ... }}}Copy the code

In the MediaItemLoader, we configure the query parameters and conditions to perform specific queries on the ContentProvider. Within the SelectedItemCollection, we use a LinkedHashSet. It is used to store the results of the user’s selections sequentially and provides a conversion method to convert a Set to a corresponding List.

@SuppressLint("ParcelCreator")
@Parcelize
data class MediaItem(

        val id: Long,
        val name: String,
        val mimeType: String,
        val size: Long,
        val time: Long,
        val uri: Uri,
        val duration: Long = 0, // only for video and audio.
        val mediaType: MediaFilterType

) : Parcelable {

    companion object {

        @JvmStatic
        fun valueOf(cursor: Cursor, mediaType: MediaFilterType): MediaItem {
            val id = cursor.getLong(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID))
            return when (mediaType) {
                MediaFilterType.IMAGE -> MediaItem(
                        id,
                        cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.TITLE)),
                        cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)),
                        cursor.getLong(cursor.getColumnIndex(MediaStore.MediaColumns.SIZE)),
                        cursor.getLong(cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)),
                        ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
                        0,
                        mediaType
                )
                // ...

}
Copy the code

MediaItem is an entity Class for concrete images, videos, audio, etc., and thanks to Kotlin’s Data Class, we don’t have to write a lot of code to do that. The MediaItem#valueOf() method converts a Cursor to a concrete entity class object.

ImageEngine & GlideEngine & PicassoEngine

interface ImageEngine {

    /**
     * Load a static image resource.
     *
     * @param context   Context
     * @param imageView ImageView widget
     * @param uri       Uri of the loaded image
     */
    fun loadImage(context: Context, imageView: ImageView, uri: Uri)

}
Copy the code

ImageEngine defines only one loadImage method for loading thumbnails of images and videos. Since we don’t have very high requirements for displaying images, this one simple method will suffice. Charles’s built-in GlideEngine and PicassoEngine implementations are also very simple:

 class GlideEngine : ImageEngine {

    override fun loadImage(context: Context, imageView: ImageView, uri: Uri) {
        Glide.with(context)
                .load(uri)
                .apply(RequestOptions().centerCrop())
                .into(imageView)
    }

}

class PicassoEngine : ImageEngine {

    override fun loadImage(context: Context, imageView: ImageView, uri: Uri) {
        Picasso.with(context)
                .load(uri)
                .fit()
                .centerCrop()
                .into(imageView)
    }

}
Copy the code

Other

  • CheckView: custom View, and implement Checkable, used to display the selected status;
  • PathUtils: Utility class used to get Path.

Write in the last

Charles developed entirely using Kotlin and open-source on Github: github.com/TonnyL/Char… Any comments or suggestions are welcome to make an issue. Of course, your PR is also welcome.

Finally, thanks to Gejiaheng (github.com/gejiaheng) and zhihu team (github.com/zhihu), Charles was inspired by The open source Matisse(github.com/zhihu/Matis…) of Zhihu team. .

Charles — An elegant way to select local multi-media files for Android

We often meet the need to choose images, videos or other files from local storage during android development. One solution is call the DocumentsUI or other file manager. But as you know, Android is open sourced, which means that the UI of each system is much varied. But what if we need a unified UI? Then it comes to the second solution: Create our own file selector.

Let’s take a look to Charles:

Charles Style Charles Dark Style Empty View

How to use Charles? The answer is pretty simple:

Charles.from(this@MainActivity)
 .choose()
 .maxSelectable(9)
 .progressRate(true)
 .theme(R.style.Charles)
 .imageEngine(GlideEngine())
 .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
 .forResult(REQUEST_CODE_CHOOSE)
Copy the code

How to receive the results?


override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CODE_CHOOSE && resultCode == Activity.RESULT_OK) {
        val uris = Charles.obtainResult(data)
        val paths = Charles.obtainPathResult(data)

        Log.d("charles", "uris: $uris")
        Log.d("charles", "paths: $paths")
    }
}
Copy the code

As you can see in the screenshots and code, Charles supports a lot of useful functions. Use Charles, you can:

  • Use it in Activity or Fragment;
  • Select multi-media file including images, videos, audio and documents;
  • Set max selectable count;
  • Apply different themes, including two built-in themes and custom themes. If none of the built-in themes that you are satisfied with, you could custom your themes;
  • Apply different image engines, including two built-in engines: Glide Engine & Picasso Engine. Like themes, you could custom your own image engines.
  • Restrict different screen orientations;
  • Find more by yourself.

In Charles internal, we choose Loader to load images and other multi-media files.

Now Charles is open sourced on GitHub. If you have any question or want to get more information about it, check the homepage and wiki. Find a bug? Do not hesitate to file an issue. At the end, thanks zhihu team for their project Matisse.