Refactor media views (#866)

* Migrate ImagePagerAdapter to kotlin

* Migrate ViewMediaFragment to kotlin

* Make images and videos share the same activity/pager

* Show descriptions above videos

* Cleanup

* Address code review feedback

* Migrate media fragments to constraint layout
This commit is contained in:
Levi Bard 2018-10-15 19:56:11 +02:00 committed by Konrad Pozniak
parent 1556a88d05
commit 952d2a6512
16 changed files with 632 additions and 594 deletions

View file

@ -79,16 +79,13 @@
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<activity
android:name=".ViewVideoActivity"
android:theme="@style/TuskyBaseTheme"
android:configChanges="orientation|keyboardHidden|screenSize" />
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />
<activity android:name=".ViewTagActivity" />
<activity android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" />
android:theme="@style/TuskyBaseTheme"
android:configChanges="orientation|screenSize|keyboardHidden"/>
<activity android:name=".AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden"/>
<activity android:name=".EditProfileActivity" />

View file

@ -15,31 +15,22 @@
package com.keylesspalace.tusky;
import android.Manifest;
import android.app.DownloadManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.design.widget.Snackbar;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.util.TypedValue;
import android.view.Menu;
import android.view.View;
import android.widget.Toast;
import com.evernote.android.job.JobManager;
import com.evernote.android.job.JobRequest;
@ -48,7 +39,6 @@ import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
@ -192,28 +182,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
.scheduleAsync();
}
protected void downloadFile(String url) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE);
} else {
String filename = new File(url).getName();
String toastText = String.format(getResources().getString(R.string.download_image), filename);
Toast.makeText(getApplicationContext(), toastText, Toast.LENGTH_SHORT).show();
DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
request.allowScanningByMediaScanner();
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename);
downloadManager.enqueue(request);
}
}
protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) {
if (anyView != null) {
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);

View file

@ -15,8 +15,10 @@
package com.keylesspalace.tusky
import android.Manifest
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@ -24,16 +26,23 @@ import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
import android.support.v4.content.FileProvider
import android.support.v4.view.ViewPager
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.webkit.MimeTypeMap
import android.widget.Toast
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.fragment.ViewMediaFragment
import com.keylesspalace.tusky.pager.AvatarImagePagerAdapter
import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.util.CollectionUtil.map
@ -50,7 +59,7 @@ import java.io.FileOutputStream
import java.io.IOException
import java.util.ArrayList
class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener {
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener {
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"
@ -114,12 +123,10 @@ class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener
viewPager.adapter = adapter
viewPager.currentItem = initialPosition
viewPager.addOnPageChangeListener(object: ViewPager.OnPageChangeListener {
viewPager.addOnPageChangeListener(object: ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
toolbar.title = adapter.getPageTitle(position)
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
override fun onPageScrollStateChanged(state: Int) {}
})
// Setup the toolbar.
@ -133,9 +140,9 @@ class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener
toolbar.setNavigationOnClickListener { _ -> supportFinishAfterTransition() }
toolbar.setOnMenuItemClickListener { item: MenuItem ->
when (item.itemId) {
R.id.action_download -> downloadImage()
R.id.action_download -> downloadMedia()
R.id.action_open_status -> onOpenStatus()
R.id.action_share_media -> shareImage()
R.id.action_share_media -> shareMedia()
}
true
}
@ -182,16 +189,34 @@ class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener
when (requestCode) {
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadImage()
downloadMedia()
} else {
showErrorDialog(toolbar, R.string.error_media_download_permission, R.string.action_retry) { _ -> downloadImage() }
showErrorDialog(toolbar, R.string.error_media_download_permission, R.string.action_retry) { _ -> downloadMedia() }
}
}
}
}
private fun downloadImage() {
downloadFile(attachments!![viewPager.currentItem].attachment.url)
private fun downloadMedia() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE)
} else {
val url = attachments!![viewPager.currentItem].attachment.url
val filename = File(url).name
val toastText = String.format(resources.getString(R.string.download_image), filename)
Toast.makeText(applicationContext, toastText, Toast.LENGTH_SHORT).show()
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename)
downloadManager.enqueue(request)
}
}
private fun onOpenStatus() {
@ -199,7 +224,7 @@ class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl))
}
private fun shareImage() {
private fun shareMedia() {
val directory = applicationContext.getExternalFilesDir("Tusky")
if (directory == null || !(directory.exists())) {
Log.e(TAG, "Error obtaining directory to save temporary media.")
@ -207,10 +232,27 @@ class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener
}
val attachment = attachments!![viewPager.currentItem].attachment
val context = applicationContext
when(attachment.type) {
Attachment.Type.IMAGE -> shareImage(directory, attachment.url)
Attachment.Type.VIDEO,
Attachment.Type.GIFV -> shareVideo(directory, attachment.url)
else -> Log.e(TAG, "Unknown media format for sharing.")
}
}
private fun shareFile(file: File, mimeType: String?) {
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = mimeType
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
}
private fun shareImage(directory: File, url: String) {
val file = File(directory, getTemporaryMediaFilename("png"))
Picasso.with(context).load(Uri.parse(attachment.url)).into(object: Target {
Picasso.with(applicationContext).load(Uri.parse(url)).into(object: Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
try {
val stream = FileOutputStream(file)
@ -230,10 +272,23 @@ class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener
override fun onPrepareLoad(placeHolderDrawable: Drawable) { }
})
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(context, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = "image/png"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
shareFile(file, "image/png")
}
private fun shareVideo(directory: File, url: String) {
val uri = Uri.parse(url)
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = mimeTypeMap.getMimeTypeFromExtension(extension)
val filename = getTemporaryMediaFilename(extension)
val file = File(directory, filename)
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(uri)
request.setDestinationUri(Uri.fromFile(file))
request.setVisibleInDownloadsUi(false)
downloadManager.enqueue(request)
shareFile(file, mimeType)
}
}

View file

@ -1,185 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.support.v4.content.FileProvider
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.webkit.MimeTypeMap
import android.widget.MediaController
import kotlinx.android.synthetic.main.activity_view_video.*
import java.io.File
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
class ViewVideoActivity: BaseActivity() {
private val handler = Handler(Looper.getMainLooper())
private lateinit var url: String
private lateinit var statusID: String
private lateinit var statusURL: String
companion object {
private const val TAG = "ViewVideoActivity"
const val URL_EXTRA = "url"
const val STATUS_ID_EXTRA = "statusID"
const val STATUS_URL_EXTRA = "statusURL"
}
@SuppressLint("ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_view_video)
setSupportActionBar(toolbar)
val bar = supportActionBar
if (bar != null) {
bar.title = null
bar.setDisplayHomeAsUpEnabled(true)
bar.setDisplayShowHomeEnabled(true)
}
toolbar.setOnMenuItemClickListener {item ->
val id = item.itemId
when (id) {
R.id.action_download -> downloadFile(url)
R.id.action_open_status -> onOpenStatus()
R.id.action_share_media -> shareVideo()
}
true
}
url = intent.getStringExtra(URL_EXTRA)
statusID = intent.getStringExtra(STATUS_ID_EXTRA)
statusURL = intent.getStringExtra(STATUS_URL_EXTRA)
videoPlayer.setVideoPath(url)
val controller = MediaController(this)
controller.setMediaPlayer(videoPlayer)
videoPlayer.setMediaController(controller)
videoPlayer.requestFocus()
videoPlayer.setOnPreparedListener { mp ->
videoProgressBar.hide()
mp.isLooping = true
hideToolbarAfterDelay()
}
videoPlayer.start()
videoPlayer.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
handler.removeCallbacksAndMessages(null)
toolbar.animate().cancel()
toolbar.alpha = 1.0f
toolbar.show()
hideToolbarAfterDelay()
}
false
}
window.statusBarColor = Color.BLACK
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.view_media_toolbar, menu)
return true
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
when (requestCode) {
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadFile(url)
} else {
showErrorDialog(toolbar, R.string.error_media_download_permission, R.string.action_retry) { _ -> downloadFile(url) }
}
}
}
}
private fun hideToolbarAfterDelay() {
handler.postDelayed({
toolbar.animate().alpha(0.0f).setListener(object: AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
val decorView = window.decorView
val uiOptions = View.SYSTEM_UI_FLAG_LOW_PROFILE
decorView.systemUiVisibility = uiOptions
toolbar.hide()
animation.removeListener(this)
}
})
}, 3000)
}
private fun onOpenStatus() {
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, statusID, statusURL))
}
private fun shareVideo() {
val directory = applicationContext.getExternalFilesDir("Tusky")
if (directory == null || !(directory.exists())) {
Log.e(TAG, "Error obtaining directory to save temporary media.")
return
}
val uri = Uri.parse(url)
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = mimeTypeMap.getMimeTypeFromExtension(extension)
val filename = getTemporaryMediaFilename(extension)
val file = File(directory, filename)
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(uri)
request.setDestinationUri(Uri.fromFile(file))
request.setVisibleInDownloadsUi(false)
downloadManager.enqueue(request)
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = mimeType
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
}
}

View file

@ -83,9 +83,6 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesViewMediaActivity(): ViewMediaActivity
@ContributesAndroidInjector
abstract fun contributesViewVideoActivity(): ViewVideoActivity
@ContributesAndroidInjector
abstract fun contributesLicenseActivity(): LicenseActivity

View file

@ -30,7 +30,6 @@ import android.view.ViewGroup
import android.widget.ImageView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewVideoActivity
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
@ -207,7 +206,9 @@ class AccountMediaFragment : BaseFragment(), Injectable {
val type = items[currentIndex].attachment.type
when (type) {
Attachment.Type.IMAGE -> {
Attachment.Type.IMAGE,
Attachment.Type.GIFV,
Attachment.Type.VIDEO -> {
val intent = ViewMediaActivity.newIntent(context, items, currentIndex)
if (view != null && activity != null) {
val url = items[currentIndex].attachment.url
@ -218,13 +219,6 @@ class AccountMediaFragment : BaseFragment(), Injectable {
startActivity(intent)
}
}
Attachment.Type.GIFV, Attachment.Type.VIDEO -> {
val intent = Intent(context, ViewVideoActivity::class.java)
intent.putExtra(ViewVideoActivity.URL_EXTRA, items[currentIndex].attachment.url)
intent.putExtra(ViewVideoActivity.STATUS_ID_EXTRA, items[currentIndex].statusId)
intent.putExtra(ViewVideoActivity.STATUS_URL_EXTRA, items[currentIndex].statusUrl)
startActivity(intent)
}
Attachment.Type.UNKNOWN -> {
}/* Intentionally do nothing. This case is here is to handle when new attachment
* types are added to the API before code is added here to handle them. So, the

View file

@ -36,7 +36,6 @@ import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ReportActivity;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.ViewTagActivity;
import com.keylesspalace.tusky.ViewVideoActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Attachment;
@ -234,6 +233,8 @@ public abstract class SFragment extends BaseFragment {
final Attachment active = actionable.getAttachments().get(urlIndex);
Attachment.Type type = active.getType();
switch (type) {
case GIFV:
case VIDEO:
case IMAGE: {
final List<AttachmentViewData> attachments = AttachmentViewData.list(actionable);
final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments,
@ -250,15 +251,6 @@ public abstract class SFragment extends BaseFragment {
}
break;
}
case GIFV:
case VIDEO: {
Intent intent = new Intent(getContext(), ViewVideoActivity.class);
intent.putExtra(ViewVideoActivity.URL_EXTRA, active.getUrl());
intent.putExtra(ViewVideoActivity.STATUS_ID_EXTRA, actionable.getId());
intent.putExtra(ViewVideoActivity.STATUS_URL_EXTRA, actionable.getUrl());
startActivity(intent);
break;
}
case UNKNOWN: {
/* Intentionally do nothing. This case is here is to handle when new attachment
* types are added to the API before code is added here to handle them. So, the

View file

@ -0,0 +1,217 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Context
import android.os.Bundle
import android.support.v4.view.ViewCompat
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import com.github.chrisbanes.photoview.PhotoViewAttacher
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.squareup.picasso.Callback
import com.squareup.picasso.NetworkPolicy
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.activity_view_media.*
import kotlinx.android.synthetic.main.fragment_view_image.*
class ViewImageFragment : ViewMediaFragment() {
interface PhotoActionsListener {
fun onBringUp()
fun onDismiss()
fun onPhotoTap()
}
private lateinit var attacher: PhotoViewAttacher
private lateinit var photoActionsListener: PhotoActionsListener
private lateinit var toolbar: View
private var showingDescription = false
private var isDescriptionVisible = false
companion object {
private const val TAG = "ViewImageFragment"
}
override fun onAttach(context: Context) {
super.onAttach(context)
photoActionsListener = context as PhotoActionsListener
}
override fun setupMediaView(url: String) {
attacher = PhotoViewAttacher(photoView)
// Clicking outside the photo closes the viewer.
attacher.setOnOutsidePhotoTapListener { _ -> photoActionsListener.onDismiss() }
attacher.setOnClickListener { _ -> onMediaTap() }
/* A vertical swipe motion also closes the viewer. This is especially useful when the photo
* mostly fills the screen so clicking outside is difficult. */
attacher.setOnSingleFlingListener { _, _, velocityX, velocityY ->
var result = false
if (Math.abs(velocityY) > Math.abs(velocityX)) {
photoActionsListener.onDismiss()
result = true
}
result
}
// If we are the view to be shown initially...
if (arguments!!.getBoolean(ViewMediaFragment.ARG_START_POSTPONED_TRANSITION)) {
// Try to load image from disk.
Picasso.with(context)
.load(url)
.noFade()
.networkPolicy(NetworkPolicy.OFFLINE)
.into(photoView, object : Callback {
override fun onSuccess() {
// if we loaded image from disk, we should check that view is attached.
if (ViewCompat.isAttachedToWindow(photoView)) {
finishLoadingSuccessfully()
} else {
// if view is not attached yet, wait for an attachment and
// start transition when it's finally ready.
photoView.addOnAttachStateChangeListener(
object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
finishLoadingSuccessfully()
photoView.removeOnAttachStateChangeListener(this)
}
override fun onViewDetachedFromWindow(v: View?) {}
})
}
}
override fun onError() {
// if there's no image in cache, load from network and start transition
// immediately.
photoActionsListener.onBringUp()
loadImageFromNetwork(url, photoView)
}
})
} else {
// if we're not initial page, don't bother.
loadImageFromNetwork(url, photoView)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = activity!!.toolbar
return inflater.inflate(R.layout.fragment_view_image, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val arguments = this.arguments!!
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT)
val url: String?
if (attachment != null) {
url = attachment.url
val description = attachment.description
descriptionView.text = description
showingDescription = !TextUtils.isEmpty(description)
isDescriptionVisible = showingDescription
} else {
url = arguments.getString(ARG_AVATAR_URL)
if (url == null) {
throw IllegalArgumentException("attachment or avatar url has to be set")
}
showingDescription = false
isDescriptionVisible = false
}
// Setting visibility without animations so it looks nice when you scroll images
if (showingDescription && (activity as ViewMediaActivity).isToolbarVisible()) {
descriptionView.show()
} else {
descriptionView.hide()
}
setupMediaView(url)
setupToolbarVisibilityListener()
}
private fun onMediaTap() {
photoActionsListener.onPhotoTap()
}
override fun onToolbarVisibilityChange(visible: Boolean) {
if (photoView == null || !userVisibleHint) {
return
}
isDescriptionVisible = showingDescription && visible
val alpha = if (isDescriptionVisible) 1.0f else 0.0f
descriptionView.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
if (isDescriptionVisible) {
descriptionView.show()
} else {
descriptionView.hide()
}
animation.removeListener(this)
}
})
.start()
}
override fun onDetach() {
super.onDetach()
Picasso.with(context).cancelRequest(photoView)
}
private fun loadImageFromNetwork(url: String, photoView: ImageView) {
Picasso.with(context)
.load(url)
.noPlaceholder()
.networkPolicy(NetworkPolicy.NO_STORE)
.into(photoView, object : Callback {
override fun onSuccess() {
finishLoadingSuccessfully()
}
override fun onError() {
progressBar.hide()
}
})
}
private fun finishLoadingSuccessfully() {
progressBar.hide()
attacher.update()
photoActionsListener.onBringUp()
}
}

View file

@ -1,255 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.content.Context;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.view.ViewCompat;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import com.github.chrisbanes.photoview.PhotoView;
import com.github.chrisbanes.photoview.PhotoViewAttacher;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.entity.Attachment;
import com.squareup.picasso.Callback;
import com.squareup.picasso.NetworkPolicy;
import com.squareup.picasso.Picasso;
import java.util.Objects;
import kotlin.jvm.functions.Function0;
public final class ViewMediaFragment extends BaseFragment {
public interface PhotoActionsListener {
void onBringUp();
void onDismiss();
void onPhotoTap();
}
private PhotoViewAttacher attacher;
private PhotoActionsListener photoActionsListener;
private View rootView;
private PhotoView photoView;
private TextView descriptionView;
private boolean showingDescription;
private boolean isDescriptionVisible;
private Function0 toolbarVisibiltyDisposable;
private static final String ARG_START_POSTPONED_TRANSITION = "startPostponedTransition";
private static final String ARG_ATTACHMENT = "attach";
private static final String ARG_AVATAR_URL = "avatarUrl";
public static ViewMediaFragment newInstance(@NonNull Attachment attachment,
boolean shouldStartPostponedTransition) {
Bundle arguments = new Bundle(2);
ViewMediaFragment fragment = new ViewMediaFragment();
arguments.putParcelable(ARG_ATTACHMENT, attachment);
arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, shouldStartPostponedTransition);
fragment.setArguments(arguments);
return fragment;
}
public static ViewMediaFragment newAvatarInstance(@NonNull String avatarUrl) {
Bundle arguments = new Bundle(2);
ViewMediaFragment fragment = new ViewMediaFragment();
arguments.putString(ARG_AVATAR_URL, avatarUrl);
arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, true);
fragment.setArguments(arguments);
return fragment;
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
photoActionsListener = (PhotoActionsListener) context;
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, final ViewGroup container,
Bundle savedInstanceState) {
rootView = inflater.inflate(R.layout.fragment_view_media, container, false);
photoView = rootView.findViewById(R.id.view_media_image);
descriptionView = rootView.findViewById(R.id.tv_media_description);
final Bundle arguments = Objects.requireNonNull(getArguments(), "Empty arguments");
final Attachment attachment = arguments.getParcelable(ARG_ATTACHMENT);
final String url;
if(attachment != null) {
url = attachment.getUrl();
@Nullable final String description = attachment.getDescription();
descriptionView.setText(description);
showingDescription = !TextUtils.isEmpty(description);
isDescriptionVisible = showingDescription;
} else {
url = arguments.getString(ARG_AVATAR_URL);
if(url == null) {
throw new IllegalArgumentException("attachment or avatar url has to be set");
}
showingDescription = false;
isDescriptionVisible = false;
}
// Setting visibility without animations so it looks nice when you scroll images
//noinspection ConstantConditions
descriptionView.setVisibility(showingDescription
&& (((ViewMediaActivity) getActivity())).isToolbarVisible()
? View.VISIBLE : View.GONE);
attacher = new PhotoViewAttacher(photoView);
// Clicking outside the photo closes the viewer.
attacher.setOnOutsidePhotoTapListener(imageView -> photoActionsListener.onDismiss());
attacher.setOnClickListener(v -> onMediaTap());
/* A vertical swipe motion also closes the viewer. This is especially useful when the photo
* mostly fills the screen so clicking outside is difficult. */
attacher.setOnSingleFlingListener((e1, e2, velocityX, velocityY) -> {
if (Math.abs(velocityY) > Math.abs(velocityX)) {
photoActionsListener.onDismiss();
return true;
}
return false;
});
ViewCompat.setTransitionName(photoView, url);
// If we are the view to be shown initially...
if (arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)) {
// Try to load image from disk.
Picasso.with(getContext())
.load(url)
.noFade()
.networkPolicy(NetworkPolicy.OFFLINE)
.into(photoView, new Callback() {
@Override
public void onSuccess() {
// if we loaded image from disk, we should check that view is attached.
if (ViewCompat.isAttachedToWindow(photoView)) {
finishLoadingSuccessfully();
} else {
// if view is not attached yet, wait for an attachment and
// start transition when it's finally ready.
photoView.addOnAttachStateChangeListener(
new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
finishLoadingSuccessfully();
photoView.removeOnAttachStateChangeListener(this);
}
@Override
public void onViewDetachedFromWindow(View v) {
}
});
}
}
@Override
public void onError() {
// if there's no image in cache, load from network and start transition
// immediately.
photoActionsListener.onBringUp();
loadImageFromNetwork(url, photoView);
}
});
} else {
// if we're not initial page, don't bother.
loadImageFromNetwork(url, photoView);
}
toolbarVisibiltyDisposable = ((ViewMediaActivity) getActivity())
.addToolbarVisibilityListener(this::onToolbarVisibilityChange);
return rootView;
}
@Override
public void onDestroyView() {
if (toolbarVisibiltyDisposable != null) toolbarVisibiltyDisposable.invoke();
super.onDestroyView();
}
private void onMediaTap() {
photoActionsListener.onPhotoTap();
}
private void onToolbarVisibilityChange(boolean visible) {
isDescriptionVisible = showingDescription && visible;
final int visibility = isDescriptionVisible ? View.VISIBLE : View.INVISIBLE;
int alpha = isDescriptionVisible ? 1 : 0;
descriptionView.animate().alpha(alpha)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
descriptionView.setVisibility(visibility);
animation.removeListener(this);
}
})
.start();
}
@Override
public void onDetach() {
super.onDetach();
Picasso.with(getContext())
.cancelRequest(photoView);
}
private void loadImageFromNetwork(String url, ImageView photoView) {
Picasso.with(getContext())
.load(url)
.noPlaceholder()
.networkPolicy(NetworkPolicy.NO_STORE)
.into(photoView, new Callback() {
@Override
public void onSuccess() {
finishLoadingSuccessfully();
}
@Override
public void onError() {
rootView.findViewById(R.id.view_media_progress).setVisibility(View.GONE);
}
});
}
private void finishLoadingSuccessfully() {
rootView.findViewById(R.id.view_media_progress).setVisibility(View.GONE);
attacher.update();
photoActionsListener.onBringUp();
}
}

View file

@ -0,0 +1,75 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
import android.os.Bundle
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment
abstract class ViewMediaFragment : BaseFragment() {
private var toolbarVisibiltyDisposable: Function0<Boolean>? = null
abstract fun setupMediaView(url: String)
abstract fun onToolbarVisibilityChange(visible: Boolean)
companion object {
@JvmStatic protected val ARG_START_POSTPONED_TRANSITION = "startPostponedTransition"
@JvmStatic protected val ARG_ATTACHMENT = "attach"
@JvmStatic protected val ARG_AVATAR_URL = "avatarUrl"
private const val TAG = "ViewMediaFragment"
@JvmStatic
fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment {
val arguments = Bundle(2)
arguments.putParcelable(ARG_ATTACHMENT, attachment)
arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, shouldStartPostponedTransition)
val fragment = when (attachment.type) {
Attachment.Type.IMAGE -> ViewImageFragment()
Attachment.Type.VIDEO,
Attachment.Type.GIFV -> ViewVideoFragment()
else -> throw Exception("Unknown media type: $attachment")
}
fragment.arguments = arguments
return fragment
}
@JvmStatic
fun newAvatarInstance(avatarUrl: String): ViewMediaFragment {
val arguments = Bundle(2)
val fragment = ViewImageFragment()
arguments.putString(ARG_AVATAR_URL, avatarUrl)
arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, true)
fragment.arguments = arguments
return fragment
}
}
protected fun setupToolbarVisibilityListener() {
toolbarVisibiltyDisposable = (activity as ViewMediaActivity).addToolbarVisibilityListener(object: ViewMediaActivity.ToolbarVisibilityListener {
override fun onToolbarVisiblityChanged(isVisible: Boolean) {
onToolbarVisibilityChange(isVisible)
}
})
}
override fun onDestroyView() {
toolbarVisibiltyDisposable?.invoke()
super.onDestroyView()
}
}

View file

@ -0,0 +1,169 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.support.v4.view.ViewCompat
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.MediaController
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import kotlinx.android.synthetic.main.activity_view_media.*
import kotlinx.android.synthetic.main.fragment_view_video.*
class ViewVideoFragment : ViewMediaFragment() {
private lateinit var toolbar: View
private val handler = Handler(Looper.getMainLooper())
private val hideToolbar = Runnable {
// Hoist toolbar hiding to activity so it can track state across different fragments
// This is explicitly stored as runnable so that we pass it to the handler later for cancellation
mediaActivity.onPhotoTap()
}
private lateinit var mediaActivity: ViewMediaActivity
private val TOOLBAR_HIDE_DELAY_MS = 3000L
private var showingDescription = false
private var isDescriptionVisible = false
companion object {
private const val TAG = "ViewVideoFragment"
}
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
// Start/pause/resume video playback as fragment is shown/hidden
super.setUserVisibleHint(isVisibleToUser)
if (videoPlayer == null) {
return
}
if (isVisibleToUser) {
if (mediaActivity.isToolbarVisible()) {
handler.postDelayed(hideToolbar, TOOLBAR_HIDE_DELAY_MS)
}
videoPlayer?.start()
} else {
handler.removeCallbacks(hideToolbar)
videoPlayer?.pause()
}
}
@SuppressLint("ClickableViewAccessibility")
override fun setupMediaView(url: String) {
val videoView = videoPlayer
videoView.setVideoPath(url)
val controller = MediaController(mediaActivity)
controller.setMediaPlayer(videoView)
videoView.setMediaController(controller)
videoView.requestFocus()
videoView.setOnTouchListener { _, _ ->
mediaActivity.onPhotoTap()
false
}
videoView.setOnPreparedListener { mp ->
progressBar.hide()
mp.isLooping = true
if (arguments!!.getBoolean(ViewMediaFragment.ARG_START_POSTPONED_TRANSITION)) {
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
videoView.start()
}
}
if (arguments!!.getBoolean(ViewMediaFragment.ARG_START_POSTPONED_TRANSITION)) {
mediaActivity.onBringUp()
}
}
private fun hideToolbarAfterDelay(delayMilliseconds: Long) {
handler.postDelayed(hideToolbar, delayMilliseconds)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
toolbar = activity!!.toolbar
mediaActivity = activity as ViewMediaActivity
return inflater.inflate(R.layout.fragment_view_video, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val arguments = this.arguments!!
val attachment = arguments.getParcelable<Attachment>(ViewMediaFragment.ARG_ATTACHMENT)
val url: String
if (attachment == null) {
throw IllegalArgumentException("attachment has to be set")
}
url = attachment.url
val description = attachment.description
mediaDescription.text = description
showingDescription = !TextUtils.isEmpty(description)
isDescriptionVisible = showingDescription
// Setting visibility without animations so it looks nice when you scroll media
//noinspection ConstantConditions
if (showingDescription && mediaActivity.isToolbarVisible()) {
mediaDescription.show()
} else {
mediaDescription.hide()
}
ViewCompat.setTransitionName(videoPlayer!!, url)
setupMediaView(url)
setupToolbarVisibilityListener()
}
override fun onToolbarVisibilityChange(visible: Boolean) {
if (videoPlayer == null || !userVisibleHint) {
return
}
isDescriptionVisible = showingDescription && visible
val alpha = if (isDescriptionVisible) 1.0f else 0.0f
mediaDescription.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
if (isDescriptionVisible) {
mediaDescription.show()
} else {
mediaDescription.hide()
}
animation.removeListener(this)
}
})
.start()
if (visible) {
hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)
} else {
handler.removeCallbacks(hideToolbar)
}
}
}

View file

@ -1,42 +0,0 @@
package com.keylesspalace.tusky.pager;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.fragment.ViewMediaFragment;
import java.util.List;
import java.util.Locale;
public final class ImagePagerAdapter extends FragmentPagerAdapter {
private List<Attachment> attachments;
private int initialPosition;
public ImagePagerAdapter(FragmentManager fragmentManager, List<Attachment> attachments, int initialPosition) {
super(fragmentManager);
this.attachments = attachments;
this.initialPosition = initialPosition;
}
@Override
public Fragment getItem(int position) {
if (position >= 0 && position < attachments.size()) {
return ViewMediaFragment.newInstance(attachments.get(position), position == initialPosition);
} else {
return null;
}
}
@Override
public int getCount() {
return attachments.size();
}
@Override
public CharSequence getPageTitle(int position) {
return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments.size());
}
}

View file

@ -0,0 +1,33 @@
package com.keylesspalace.tusky.pager
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import android.support.v4.app.FragmentStatePagerAdapter
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewMediaFragment
import java.util.Locale
class ImagePagerAdapter(
fragmentManager: FragmentManager,
private val attachments: List<Attachment>,
private val initialPosition: Int
) : FragmentStatePagerAdapter(fragmentManager) {
override fun getItem(position: Int): Fragment? {
return if (position >= 0 && position < attachments.size) {
ViewMediaFragment.newInstance(attachments[position], position == initialPosition)
} else {
null
}
}
override fun getCount(): Int {
return attachments.size
}
override fun getPageTitle(position: Int): CharSequence {
return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments.size)
}
}

View file

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/view_video_background"
android:id="@+id/view_video_container"
tools:context=".ViewVideoActivity">
<VideoView
android:id="@+id/videoPlayer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
<ProgressBar
android:id="@+id/videoProgressBar"
android:layout_gravity="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/AppTheme.Account.AppBarLayout"
app:popupTheme="?attr/toolbar_popup_theme"
android:background="@color/toolbar_view_media" />
</android.support.design.widget.CoordinatorLayout>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
@ -8,23 +9,27 @@
android:focusable="true">
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/view_media_image"
android:id="@+id/photoView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/view_media_progress"
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:layout_gravity="center" />
<TextView
android:id="@+id/tv_media_description"
android:id="@+id/descriptionView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
app:layout_constraintBottom_toBottomOf="parent"
android:background="#60000000"
android:lineSpacingMultiplier="1.1"
android:padding="8dp"
@ -33,4 +38,4 @@
android:textSize="?attr/status_text_medium"
tools:text="Some media description" />
</RelativeLayout>
</android.support.constraint.ConstraintLayout>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_gravity="center"
android:clickable="true"
android:focusable="true">
<TextView
android:id="@+id/mediaDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/toolbar"
app:layout_constraintBottom_toTopOf="@+id/videoPlayer"
android:background="#60000000"
android:lineSpacingMultiplier="1.1"
android:padding="8dp"
android:textAlignment="center"
android:textColor="#eee"
android:textSize="?attr/status_text_medium"
tools:text="Some media description" />
<VideoView
android:id="@+id/videoPlayer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_gravity="center"
/>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_gravity="center" />
</android.support.constraint.ConstraintLayout>