From 7fa32c273df8f0fa2d9f8d9c606a58094445e8a1 Mon Sep 17 00:00:00 2001 From: khanhduytran0 Date: Sun, 4 Oct 2020 09:30:51 +0700 Subject: [PATCH] WIP design --- .../pojavlaunch/PojavLauncherActivity.java | 886 +++++++++ .../kdt/pojavlaunch/PojavLoginActivity.java | 10 +- .../pojavlaunch/PojavV2ActivityManager.java | 2 +- .../kdt/pojavlaunch/V3LauncherActivity.java | 13 + .../launcheruiv3/VerticalTabLayout.java.z | 1631 +++++++++++++++++ 5 files changed, 2535 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/net/kdt/pojavlaunch/PojavLauncherActivity.java create mode 100644 app/src/main/java/net/kdt/pojavlaunch/V3LauncherActivity.java create mode 100644 app/src/main/java/net/kdt/pojavlaunch/launcheruiv3/VerticalTabLayout.java.z diff --git a/app/src/main/java/net/kdt/pojavlaunch/PojavLauncherActivity.java b/app/src/main/java/net/kdt/pojavlaunch/PojavLauncherActivity.java new file mode 100644 index 000000000..d54fc09f4 --- /dev/null +++ b/app/src/main/java/net/kdt/pojavlaunch/PojavLauncherActivity.java @@ -0,0 +1,886 @@ +package net.kdt.pojavlaunch; + +import android.app.*; +import android.content.*; +import android.graphics.*; +import android.os.*; +import android.support.design.widget.*; +import android.support.v4.app.*; +import android.support.v7.app.*; +import android.text.*; +import android.util.*; +import android.view.*; +import android.widget.*; +import android.widget.AdapterView.*; +import com.google.gson.*; +import com.kdt.filerapi.*; +import java.io.*; +import java.nio.charset.*; +import java.util.*; +import net.kdt.pojavlaunch.launcheruiv3.*; +import net.kdt.pojavlaunch.mcfragments.*; +import net.kdt.pojavlaunch.prefs.*; +import net.kdt.pojavlaunch.util.*; +import net.kdt.pojavlaunch.value.*; +import org.lwjgl.glfw.*; + +import android.app.AlertDialog; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.view.*; +//import android.support.v7.view.menu.*; +//import net.zhuoweizhang.boardwalk.downloader.*; + +public class PojavLauncherActivity extends AppCompatActivity +{ + //private FragmentTabHost mTabHost; + private LinearLayout fullTab; + /* + private PojavLauncherViewPager viewPager; + private VerticalTabLayout tabLayout; + */ + + private PojavLauncherViewPager viewPager; + private VerticalTabLayout tabLayout; + + private TextView tvVersion, tvUsernameView; + private Spinner versionSelector; + private String[] availableVersions = Tools.versionList; + private MCProfile.Builder profile; + private String profilePath = null; + private CrashFragment crashView; + private ConsoleFragment consoleView; + private ViewPagerAdapter viewPageAdapter; + + private ProgressBar launchProgress; + private TextView launchTextStatus; + private Button switchUsrBtn, logoutBtn; // MineButtons + private ViewGroup leftView, rightView; + private Button playButton; + + private Gson gson; + + private JMinecraftVersionList versionList; + private static volatile boolean isAssetsProcessing = false; + + @Override + protected void onCreate(Bundle savedInstanceState) + { + super.onCreate(savedInstanceState); + gson = new Gson(); + + viewInit(); + + final View decorView = getWindow().getDecorView(); + decorView.setOnSystemUiVisibilityChangeListener (new View.OnSystemUiVisibilityChangeListener() { + @Override + public void onSystemUiVisibilityChange(int visibility) { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + decorView.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + } + } + }); + + if (BuildConfig.DEBUG) + Toast.makeText(this, "Launcher process id: " + android.os.Process.myPid(), Toast.LENGTH_LONG).show(); + } + // DEBUG + //new android.support.design.widget.NavigationView(this); + + private String getStr(int id, Object... val) { + if (val != null && val.length > 0) { + return getResources().getString(id, val); + } else { + return getResources().getString(id); + } + } + + private void viewInit() { + setContentView(R.layout.launcher_main_v3); + // setContentView(R.layout.launcher_main); + + fullTab = findViewById(R.id.launchermainFragmentTabView); + tabLayout = findViewById(R.id.launchermainTabLayout); + viewPager = findViewById(R.id.launchermainTabPager); + + consoleView = new ConsoleFragment(); + crashView = new CrashFragment(); + + viewPageAdapter = new ViewPagerAdapter(getSupportFragmentManager()); + + viewPageAdapter.addFragment(new LauncherFragment(), getStr(R.string.mcl_tab_news)); + viewPageAdapter.addFragment(consoleView, getStr(R.string.mcl_tab_console)); + viewPageAdapter.addFragment(crashView, getStr(R.string.mcl_tab_crash)); + + viewPager.setAdapter(viewPageAdapter); + tabLayout.setupWithViewPager(viewPager); + + tvUsernameView = (TextView) findId(R.id.launcherMainUsernameView); + tvVersion = (TextView) findId(R.id.launcherMainVersionView); + + try { + profilePath = PojavProfile.getCurrentProfilePath(this); + profile = PojavProfile.getCurrentProfileContent(this); + + tvUsernameView.setText(profile.getUsername()); + } catch(Exception e) { + //Tools.throwError(this, e); + e.printStackTrace(); + Toast.makeText(this, getStr(R.string.toast_login_error, e.getMessage()), Toast.LENGTH_LONG).show(); + finish(); + } + + File logFile = new File(Tools.MAIN_PATH, "latestlog.txt"); + if (logFile.exists() && logFile.length() < 20480) { + String errMsg = "Error occurred during initialization of "; + try { + String logContent = Tools.read(logFile.getAbsolutePath()); + if (logContent.contains(errMsg + "VM") && + logContent.contains("Could not reserve enough space for")) { + OutOfMemoryError ex = new OutOfMemoryError("Java error: " + logContent); + ex.setStackTrace(null); + Tools.showError(PojavLauncherActivity.this, ex); + + // Do it so dialog will not shown for second time + Tools.write(logFile.getAbsolutePath(), logContent.replace(errMsg + "VM", errMsg + "JVM")); + } + } catch (Throwable th) { + System.err.println("Could not detect java crash"); + th.printStackTrace(); + } + } + + //showProfileInfo(); + + List versions = new ArrayList(); + final File fVers = new File(Tools.versnDir); + + try { + if (fVers.listFiles().length < 1) { + throw new Exception(getStr(R.string.error_no_version)); + } + + for (File fVer : fVers.listFiles()) { + if (fVer.isDirectory()) + versions.add(fVer.getName()); + } + } catch (Exception e) { + versions.add(getStr(R.string.global_error) + ":"); + versions.add(e.getMessage()); + + } finally { + availableVersions = versions.toArray(new String[0]); + } + + //availableVersions; + + ArrayAdapter adapter = new ArrayAdapter(this, android.R.layout.simple_spinner_item, availableVersions); + adapter.setDropDownViewResource(android.R.layout.simple_list_item_single_choice); + versionSelector = (Spinner) findId(R.id.launcherMainSelectVersion); + versionSelector.setAdapter(adapter); + + launchProgress = (ProgressBar) findId(R.id.progressDownloadBar); + launchTextStatus = (TextView) findId(R.id.progressDownloadText); + LinearLayout exitLayout = (LinearLayout) findId(R.id.launcherMainExitbtns); + switchUsrBtn = (Button) exitLayout.getChildAt(0); + logoutBtn = (Button) exitLayout.getChildAt(1); + + leftView = (LinearLayout) findId(R.id.launcherMainLeftLayout); + playButton = (Button) findId(R.id.launcherMainPlayButton); + rightView = (ViewGroup) findId(R.id.launcherMainRightLayout); + + statusIsLaunching(false); + } + + public class RefreshVersionListTask extends AsyncTask>{ + + @Override + protected ArrayList doInBackground(Void[] p1) + { + try{ + versionList = gson.fromJson(DownloadUtils.downloadString("https://launchermeta.mojang.com/mc/game/version_manifest.json"), JMinecraftVersionList.class); + ArrayList versionStringList = filter(versionList.versions, new File(Tools.versnDir).listFiles()); + + return versionStringList; + } catch (Exception e){ + e.printStackTrace(); + } + return null; + } + + @Override + protected void onPostExecute(ArrayList result) + { + super.onPostExecute(result); + + final PopupMenu popup = new PopupMenu(PojavLauncherActivity.this, versionSelector); + popup.getMenuInflater().inflate(R.menu.menu_versionopt, popup.getMenu()); + + if(result != null && result.size() > 0) { + ArrayAdapter adapter = new ArrayAdapter(PojavLauncherActivity.this, android.R.layout.simple_spinner_item, result); + adapter.setDropDownViewResource(android.R.layout.simple_list_item_single_choice); + versionSelector.setAdapter(adapter); + versionSelector.setSelection(selectAt(result.toArray(new String[0]), profile.getVersion())); + } else { + versionSelector.setSelection(selectAt(availableVersions, profile.getVersion())); + } + versionSelector.setOnItemSelectedListener(new OnItemSelectedListener(){ + + @Override + public void onItemSelected(AdapterView p1, View p2, int p3, long p4) + { + String version = p1.getItemAtPosition(p3).toString(); + profile.setVersion(version); + + PojavProfile.setCurrentProfile(PojavLauncherActivity.this, profile); + if (PojavProfile.isFileType(PojavLauncherActivity.this)) { + PojavProfile.setCurrentProfile(PojavLauncherActivity.this, MCProfile.build(profile)); + } + + tvVersion.setText(getStr(R.string.mcl_version_msg, version)); + } + + @Override + public void onNothingSelected(AdapterView p1) + { + // TODO: Implement this method + } + }); + versionSelector.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener(){ + @Override + public boolean onItemLongClick(AdapterView p1, View p2, int p3, long p4) + { + // Implement copy, remove, reinstall,... + popup.show(); + return true; + } + }); + + popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + public boolean onMenuItemClick(MenuItem item) { + return true; + } + }); + + tvVersion.setText(getStr(R.string.mcl_version_msg) + versionSelector.getSelectedItem()); + } + } + + @Override + protected void onPostResume() { + super.onPostResume(); + Tools.updateWindowSize(this); + } + + private float updateWidthHeight() { + float leftRightWidth = (float) CallbackBridge.windowWidth / 100f * 32f; + float playButtonWidth = CallbackBridge.windowWidth - leftRightWidth * 2f; + LinearLayout.LayoutParams leftRightParams = new LinearLayout.LayoutParams((int) leftRightWidth, (int) Tools.dpToPx(this, CallbackBridge.windowHeight / 9)); + LinearLayout.LayoutParams playButtonParams = new LinearLayout.LayoutParams((int) playButtonWidth, (int) Tools.dpToPx(this, CallbackBridge.windowHeight / 9)); + leftView.setLayoutParams(leftRightParams); + rightView.setLayoutParams(leftRightParams); + playButton.setLayoutParams(playButtonParams); + + return leftRightWidth; + } + + private JMinecraftVersionList.Version findVersion(String version) { + if (versionList != null) { + for (JMinecraftVersionList.Version valueVer: versionList.versions) { + if (valueVer.id.equals(version)) { + return valueVer; + } + } + } + + // Custom version, inherits from base. + return Tools.getVersionInfo(version); + } + + private ArrayList filter(JMinecraftVersionList.Version[] list1, File[] list2) { + ArrayList output = new ArrayList(); + + for (JMinecraftVersionList.Version value1: list1) { + if ((value1.type.equals("release") && LauncherPreferences.PREF_VERTYPE_RELEASE) || + (value1.type.equals("snapshot") && LauncherPreferences.PREF_VERTYPE_SNAPSHOT) || + (value1.type.equals("old_alpha") && LauncherPreferences.PREF_VERTYPE_OLDALPHA) || + (value1.type.equals("old_beta") && LauncherPreferences.PREF_VERTYPE_OLDBETA)) { + output.add(value1.id); + } + } + + for (File value2: list2) { + if (!output.contains(value2.getName())) { + output.add(value2.getName()); + } + } + + return output; + } + + public void mcaccSwitchUser(View view) + { + showProfileInfo(); + } + + public void mcaccLogout(View view) + { + //PojavProfile.reset(); + finish(); + } + + private void showProfileInfo() + { + /* + new AlertDialog.Builder(this) + .setTitle("Info player") + .setMessage( + "AccessToken=" + profile.getAccessToken() + "\n" + + "ClientID=" + profile.getClientID() + "\n" + + "ProfileID=" + profile.getProfileID() + "\n" + + "Username=" + profile.getUsername() + "\n" + + "Version=" + profile.getVersion() + ).show(); + */ + } + + private void selectTabPage(int pageIndex){ + if (tabLayout.getSelectedTabPosition() != pageIndex) { + tabLayout.setScrollPosition(pageIndex,0f,true); + viewPager.setCurrentItem(pageIndex); + } + } + + @Override + protected void onResumeFragments() + { + super.onResumeFragments(); + new RefreshVersionListTask().execute(); + + try{ + final ProgressDialog barrier = new ProgressDialog(this); + barrier.setMessage("Waiting"); + barrier.setProgressStyle(barrier.STYLE_SPINNER); + barrier.setCancelable(false); + barrier.show(); + + new Thread(new Runnable(){ + + @Override + public void run() + { + while (consoleView == null) { + try { + Thread.sleep(20); + } catch (Throwable th) {} + } + + try { + Thread.sleep(100); + } catch (Throwable th) {} + + runOnUiThread(new Runnable() { + @Override + public void run() + { + try { + consoleView.putLog(""); + barrier.dismiss(); + } catch (Throwable th) { + startActivity(getIntent()); + finish(); + } + } + }); + } + }).start(); + + File lastCrashFile = Tools.lastFileModified(Tools.crashPath); + if(CrashFragment.isNewCrash(lastCrashFile) || !crashView.getLastCrash().isEmpty()){ + crashView.resetCrashLog = false; + selectTabPage(2); + } else throw new Exception(); + } catch(Throwable e){ + selectTabPage(tabLayout.getSelectedTabPosition()); + } + } + + public int selectAt(String[] strArr, String select) + { + int count = 0; + for(String str : strArr){ + if(str.equals(select)){ + return count; + } + else{ + count++; + } + } + return -1; + } + + @Override + protected void onResume(){ + super.onResume(); + final int uiOptions = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + final View decorView = getWindow().getDecorView(); + decorView.setSystemUiVisibility(uiOptions); + } + + private boolean canBack = false; + private void statusIsLaunching(boolean isLaunching) + { + LinearLayout.LayoutParams reparam = new LinearLayout.LayoutParams((int) updateWidthHeight(), LinearLayout.LayoutParams.WRAP_CONTENT); + ViewGroup.MarginLayoutParams lmainTabParam = (ViewGroup.MarginLayoutParams) fullTab.getLayoutParams(); + int launchVisibility = isLaunching ? View.VISIBLE : View.GONE; + launchProgress.setVisibility(launchVisibility); + launchTextStatus.setVisibility(launchVisibility); + lmainTabParam.bottomMargin = reparam.height; + leftView.setLayoutParams(reparam); + + switchUsrBtn.setEnabled(!isLaunching); + logoutBtn.setEnabled(!isLaunching); + versionSelector.setEnabled(!isLaunching); + canBack = !isLaunching; + } + @Override + public void onBackPressed() + { + if (canBack) { + super.onBackPressed(); + } + } + + // Catching touch exception + @Override + public boolean onTouchEvent(MotionEvent event) { + try { + return super.onTouchEvent(event); + } catch (Throwable th) { + Tools.showError(this, th); + return false; + } + } + + private GameRunnerTask mTask; + + public void launchGame(View v) + { + if (!canBack && isAssetsProcessing) { + isAssetsProcessing = false; + statusIsLaunching(false); + } else if (canBack) { + v.setEnabled(false); + mTask = new GameRunnerTask(); + mTask.execute(profile.getVersion()); + crashView.resetCrashLog = true; + } + } + + public class GameRunnerTask extends AsyncTask + { + private boolean launchWithError = false; + + @Override + protected void onPreExecute() { + launchProgress.setMax(1); + statusIsLaunching(true); + } + + private JMinecraftVersionList.Version verInfo; + @Override + protected Throwable doInBackground(final String[] p1) { + Throwable throwable = null; + try { + final String downVName = "/" + p1[0] + "/" + p1[0]; + + //Downloading libraries + String inputPath = Tools.versnDir + downVName + ".jar"; + JAssets assets = null; + try { + //com.pojavdx.dx.mod.Main.debug = true; + + String verJsonDir = Tools.versnDir + downVName + ".json"; + + verInfo = findVersion(p1[0]); + + if (verInfo.url != null) { + publishProgress("1", "Downloading " + p1[0] + " configuration..."); + Tools.downloadFile( + verInfo.url, + verJsonDir, + true + ); + } + + verInfo = Tools.getVersionInfo(p1[0]); + assets = downloadIndex(verInfo.assets, new File(Tools.ASSETS_PATH, "indexes/" + verInfo.assets + ".json")); + + File outLib; + String libPathURL; + + setMax(verInfo.libraries.length + 4 + assets.objects.size()); + for (final DependentLibrary libItem : verInfo.libraries) { + + if (// libItem.name.startsWith("com.google.code.gson:gson") || + // libItem.name.startsWith("com.mojang:realms") || + libItem.name.startsWith("net.java.jinput") || + // libItem.name.startsWith("net.minecraft.launchwrapper") || + + // FIXME lib below! + // libItem.name.startsWith("optifine:launchwrapper-of") || + + // libItem.name.startsWith("org.lwjgl.lwjgl:lwjgl") || + libItem.name.startsWith("org.lwjgl") + // libItem.name.startsWith("tv.twitch") + ) { // Black list + publishProgress("1", "Ignored " + libItem.name); + //Thread.sleep(100); + } else { + + String[] libInfo = libItem.name.split(":"); + String libArtifact = Tools.artifactToPath(libInfo[0], libInfo[1], libInfo[2]); + outLib = new File(Tools.libraries + "/" + libArtifact); + outLib.getParentFile().mkdirs(); + + if (!outLib.exists()) { + publishProgress("1", getStr(R.string.mcl_launch_download_lib, libItem.name)); + + boolean skipIfFailed = false; + + if (libItem.downloads == null || libItem.downloads.artifact == null) { + MinecraftLibraryArtifact artifact = new MinecraftLibraryArtifact(); + artifact.url = (libItem.url == null ? "https://libraries.minecraft.net/" : libItem.url) + libArtifact; + libItem.downloads = new DependentLibrary.LibraryDownloads(artifact); + + skipIfFailed = true; + } + try { + libPathURL = libItem.downloads.artifact.url; + Tools.downloadFile( + libPathURL, + outLib.getAbsolutePath(), + true + ); + } catch (Throwable th) { + if (!skipIfFailed) { + throw th; + } else { + th.printStackTrace(); + publishProgress("0", th.getMessage()); + } + } + } + } + } + + publishProgress("1", getStr(R.string.mcl_launch_download_client, p1[0])); + Tools.downloadFile( + verInfo.downloads.values().toArray(new MinecraftClientInfo[0])[0].url, + inputPath, + true + ); + } catch (Throwable e) { + launchWithError = true; + throw e; + } + + publishProgress("1", getStr(R.string.mcl_launch_cleancache)); + // new File(inputPath).delete(); + + for (File f : new File(Tools.versnDir).listFiles()) { + if(f.getName().endsWith(".part")) { + Log.d(Tools.APP_NAME, "Cleaning cache: " + f); + f.delete(); + } + } + + isAssetsProcessing = true; + playButton.post(new Runnable(){ + + @Override + public void run() + { + playButton.setText("Skip"); + playButton.setEnabled(true); + } + }); + publishProgress("1", getStr(R.string.mcl_launch_download_assets)); + try { + downloadAssets(assets, verInfo.assets, new File(Tools.ASSETS_PATH)); + } catch (Exception e) { + e.printStackTrace(); + + // Ignore it + launchWithError = false; + } finally { + isAssetsProcessing = false; + } + } catch (Throwable th){ + throwable = th; + } finally { + return throwable; + } + } + private int addProgress = 0; // 34 + + public void zeroProgress() + { + addProgress = 0; + } + + public void setMax(final int value) + { + launchProgress.post(new Runnable(){ + + @Override + public void run() + { + launchProgress.setMax(value); + } + }); + } + + @Override + protected void onProgressUpdate(String... p1) + { + int addedProg = Integer.parseInt(p1[0]); + if (addedProg != -1) { + addProgress = addProgress + addedProg; + launchProgress.setProgress(addProgress); + + launchTextStatus.setText(p1[1]); + } + + if (p1.length < 3) consoleView.putLog(p1[1] + (p1.length < 3 ? "\n" : "")); + } + + @Override + protected void onPostExecute(Throwable p1) + { + playButton.setText("Play"); + playButton.setEnabled(true); + launchProgress.setMax(100); + launchProgress.setProgress(0); + statusIsLaunching(false); + if(p1 != null) { + p1.printStackTrace(); + Tools.showError(PojavLauncherActivity.this, p1); + } + if(!launchWithError) { + crashView.setLastCrash(""); + + try { + /* + List jvmArgs = ManagementFactory.getRuntimeMXBean().getInputArguments(); + jvmArgs.add("-Xms128M"); + jvmArgs.add("-Xmx1G"); + */ + Intent mainIntent = new Intent(PojavLauncherActivity.this, MainActivity.class); + // mainIntent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); + mainIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + mainIntent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + if (LauncherPreferences.PREF_FREEFORM) { + DisplayMetrics dm = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(dm); + + ActivityOptions options = (ActivityOptions) ActivityOptions.class.getMethod("makeBasic").invoke(null); + Rect freeformRect = new Rect(0, 0, dm.widthPixels / 2, dm.heightPixels / 2); + options.getClass().getDeclaredMethod("setLaunchBounds", Rect.class).invoke(options, freeformRect); + startActivity(mainIntent, options.toBundle()); + } else { + startActivity(mainIntent); + } + } + catch (Throwable e) { + Tools.showError(PojavLauncherActivity.this, e); + } + + /* + FloatingIntent maini = new FloatingIntent(PojavLauncherActivity.this, MainActivity.class); + maini.startFloatingActivity(); + */ + } + + mTask = null; + } + + private Gson gsonss = gson; + public static final String MINECRAFT_RES = "http://resources.download.minecraft.net/"; + + public JAssets downloadIndex(String versionName, File output) throws Exception { + String versionJson = DownloadUtils.downloadString(verInfo.assetIndex != null ? verInfo.assetIndex.url : "http://s3.amazonaws.com/Minecraft.Download/indexes/" + versionName + ".json"); + JAssets version = gsonss.fromJson(versionJson, JAssets.class); + output.getParentFile().mkdirs(); + Tools.write(output.getAbsolutePath(), versionJson.getBytes(Charset.forName("UTF-8"))); + return version; + } + + public void downloadAsset(JAssetInfo asset, File objectsDir) throws IOException, Throwable { + String assetPath = asset.hash.substring(0, 2) + "/" + asset.hash; + File outFile = new File(objectsDir, assetPath); + if (!outFile.exists()) { + DownloadUtils.downloadFile(MINECRAFT_RES + assetPath, outFile); + } + } + + public void downloadAssets(JAssets assets, String assetsVersion, File outputDir) throws IOException, Throwable { + File hasDownloadedFile = new File(outputDir, "downloaded/" + assetsVersion + ".downloaded"); + if (!hasDownloadedFile.exists()) { + System.out.println("Assets begin time: " + System.currentTimeMillis()); + Map assetsObjects = assets.objects; + launchProgress.setMax(assetsObjects.size()); + zeroProgress(); + File objectsDir = new File(outputDir, "objects"); + int downloadedSs = 0; + for (JAssetInfo asset : assetsObjects.values()) { + if (!isAssetsProcessing) { + return; + } + + downloadAsset(asset, objectsDir); + publishProgress("1", getStr(R.string.mcl_launch_downloading, assetsObjects.keySet().toArray(new String[0])[downloadedSs])); + downloadedSs++; + } + hasDownloadedFile.getParentFile().mkdirs(); + hasDownloadedFile.createNewFile(); + System.out.println("Assets end time: " + System.currentTimeMillis()); + } + } + } + public View findId(int id) + { + return findViewById(id); + } + + public void launcherMenu(View view) + { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.mcl_options); + builder.setItems(R.array.mcl_options, new DialogInterface.OnClickListener(){ + + @Override + public void onClick(DialogInterface p1, int p2) + { + switch (p2) { + case 0: // Mod installer + installMod(false); + break; + case 1: // Mod installer with java args + installMod(true); + break; + case 2: // Custom controls + if (Tools.enableDevFeatures) { + startActivity(new Intent(PojavLauncherActivity.this, CustomControlsActivity.class)); + } + break; + case 3: // Settings + startActivity(new Intent(PojavLauncherActivity.this, LauncherPreferenceActivity.class)); + break; + case 4: { // About + final AlertDialog.Builder aboutB = new AlertDialog.Builder(PojavLauncherActivity.this); + aboutB.setTitle(R.string.mcl_option_about); + try + { + aboutB.setMessage(Html.fromHtml(String.format(Tools.read(getAssets().open("about_en.txt")), + Tools.APP_NAME, + Tools.usingVerName, + "3.2.3") + )); + } catch (Exception e) { + throw new RuntimeException(e); + } + aboutB.setPositiveButton(android.R.string.ok, null); + aboutB.show(); + } break; + } + } + }); + builder.show(); + } + + private void installMod(boolean customJavaArgs) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.alerttitle_installmod); + builder.setNegativeButton(android.R.string.cancel, null); + + final AlertDialog dialog; + if (customJavaArgs) { + final EditText edit = new EditText(this); + edit.setSingleLine(); + edit.setHint("-jar/-cp /path/to/file.jar ..."); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener(){ + @Override + public void onClick(DialogInterface di, int i) { + Intent intent = new Intent(PojavLauncherActivity.this, InstallModActivity.class); + intent.putExtra("javaArgs", edit.getText().toString()); + startActivity(intent); + } + }); + dialog = builder.create(); + dialog.setView(edit); + } else { + dialog = builder.create(); + FileListView flv = new FileListView(this); + flv.setFileSelectedListener(new FileSelectedListener(){ + + @Override + public void onFileSelected(File file, String path, String name) { + if (name.endsWith(".jar")) { + Intent intent = new Intent(PojavLauncherActivity.this, InstallModActivity.class); + intent.putExtra("modFile", file); + startActivity(intent); + dialog.dismiss(); + } + } + }); + dialog.setView(flv); + } + dialog.show(); + } + + private class ViewPagerAdapter extends FragmentPagerAdapter { + + List fragmentList = new ArrayList<>(); + List fragmentTitles = new ArrayList<>(); + + public ViewPagerAdapter(FragmentManager fragmentManager) { + super(fragmentManager); + } + + @Override + public Fragment getItem(int position) { + return fragmentList.get(position); + } + + @Override + public int getCount() { + return fragmentList.size(); + } + + @Override + public CharSequence getPageTitle(int position) { + return fragmentTitles.get(position); + } + + public void addFragment(Fragment fragment, String name) { + fragmentList.add(fragment); + fragmentTitles.add(name); + } + + public void setFragment(int index, Fragment fragment, String name) { + fragmentList.set(index, fragment); + fragmentTitles.set(index, name); + } + + public void removeFragment(int index) { + fragmentList.remove(index); + fragmentTitles.remove(index); + } + } +} diff --git a/app/src/main/java/net/kdt/pojavlaunch/PojavLoginActivity.java b/app/src/main/java/net/kdt/pojavlaunch/PojavLoginActivity.java index ae5bd5202..547525b37 100644 --- a/app/src/main/java/net/kdt/pojavlaunch/PojavLoginActivity.java +++ b/app/src/main/java/net/kdt/pojavlaunch/PojavLoginActivity.java @@ -494,18 +494,16 @@ public class PojavLoginActivity extends AppCompatActivity AlertDialog.Builder builder = new AlertDialog.Builder(this); if (Tools.enableDevFeatures) { - /* - builder.setNegativeButton("Toggle v2", new DialogInterface.OnClickListener(){ + builder.setNegativeButton("Toggle UI v2", new DialogInterface.OnClickListener(){ @Override public void onClick(DialogInterface p1, int p2) { - int ver = PojavV2ActivityManager.getLauncherRemakeInt(MCLoginActivity.this) == 0 ? 1 : 0; - PojavV2ActivityManager.setLauncherRemakeVer(MCLoginActivity.this, ver); - Toast.makeText(MCLoginActivity.this, "Changed to use v" + (ver + 1), Toast.LENGTH_SHORT).show(); + int ver = PojavV2ActivityManager.getLauncherRemakeInt(PojavLoginActivity.this) == 0 ? 1 : 0; + PojavV2ActivityManager.setLauncherRemakeVer(PojavLoginActivity.this, ver); + Toast.makeText(PojavLoginActivity.this, "Changed to use v" + (ver + 1), Toast.LENGTH_SHORT).show(); } }); - */ } builder.setPositiveButton(android.R.string.cancel, null); diff --git a/app/src/main/java/net/kdt/pojavlaunch/PojavV2ActivityManager.java b/app/src/main/java/net/kdt/pojavlaunch/PojavV2ActivityManager.java index c9e7d073b..54e91552a 100644 --- a/app/src/main/java/net/kdt/pojavlaunch/PojavV2ActivityManager.java +++ b/app/src/main/java/net/kdt/pojavlaunch/PojavV2ActivityManager.java @@ -5,7 +5,7 @@ public class PojavV2ActivityManager { public static String CATEGORY_LAUNCHER = "launcher"; public static Class LAUNCHER_V1 = MCLauncherActivity.class; - public static Class LAUNCHER_V2 = LAUNCHER_V1; // PojavLauncherActivity.class; + public static Class LAUNCHER_V2 = PojavLauncherActivity.class; public static boolean setLauncherRemakeClass(Context context, Class cls) { return setLauncherRemakeVer(context, cls.getName().equals(LAUNCHER_V1.getName()) ? 0 : 1); diff --git a/app/src/main/java/net/kdt/pojavlaunch/V3LauncherActivity.java b/app/src/main/java/net/kdt/pojavlaunch/V3LauncherActivity.java new file mode 100644 index 000000000..4a60886b6 --- /dev/null +++ b/app/src/main/java/net/kdt/pojavlaunch/V3LauncherActivity.java @@ -0,0 +1,13 @@ +package net.kdt.pojavlaunch; + +import android.support.v7.app.*; +import android.os.*; + +public class V3LauncherActivity extends AppCompatActivity +{ + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + } +} diff --git a/app/src/main/java/net/kdt/pojavlaunch/launcheruiv3/VerticalTabLayout.java.z b/app/src/main/java/net/kdt/pojavlaunch/launcheruiv3/VerticalTabLayout.java.z new file mode 100644 index 000000000..0e11a8299 --- /dev/null +++ b/app/src/main/java/net/kdt/pojavlaunch/launcheruiv3/VerticalTabLayout.java.z @@ -0,0 +1,1631 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.kdt.pojavlaunch.launcheruiv3; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.support.annotation.ColorInt; +import android.support.annotation.DrawableRes; +import android.support.annotation.IntDef; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.design.R; +import android.support.v4.math.MathUtils; +import android.support.v4.view.GravityCompat; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewCompat; +import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; +import android.support.v7.internal.widget.TintManager; +import android.support.v7.widget.ViewUtils; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.ScrollView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Iterator; + +import static android.support.v4.view.ViewPager.SCROLL_STATE_DRAGGING; +import static android.support.v4.view.ViewPager.SCROLL_STATE_IDLE; +import static android.support.v4.view.ViewPager.SCROLL_STATE_SETTLING; + +/** + * VerticalTabLayout provides a horizontal layout to display tabs. + * + *

Population of the tabs to display is + * done through {@link Tab} instances. You create tabs via {@link #newTab()}. From there you can + * change the tab's label or icon via {@link Tab#setText(int)} and {@link Tab#setIcon(int)} + * respectively. To display the tab, you need to add it to the layout via one of the + * {@link #addTab(Tab)} methods. For example: + *

+ * VerticalTabLayout VerticalTabLayout = ...;
+ * VerticalTabLayout.addTab(VerticalTabLayout.newTab().setText("Tab 1"));
+ * VerticalTabLayout.addTab(VerticalTabLayout.newTab().setText("Tab 2"));
+ * VerticalTabLayout.addTab(VerticalTabLayout.newTab().setText("Tab 3"));
+ * 
+ * You should set a listener via {@link #setOnTabSelectedListener(OnTabSelectedListener)} to be + * notified when any tab's selection state has been changed. + *

+ * If you're using a {@link android.support.v4.view.ViewPager} together + * with this layout, you can use {@link #setTabsFromPagerAdapter(PagerAdapter)} which will populate + * the tabs using the given {@link PagerAdapter}'s page titles. You should also use a + * {@link VerticalTabLayoutOnPageChangeListener} to forward the scroll and selection changes to this + * layout like so: + *

+ * ViewPager viewPager = ...;
+ * VerticalTabLayout VerticalTabLayout = ...;
+ * viewPager.addOnPageChangeListener(new VerticalTabLayoutOnPageChangeListener(VerticalTabLayout));
+ * 
+ * + * @see Tabs + */ +public class VerticalTabLayout extends ScrollView { + + private static final int MAX_TAB_TEXT_LINES = 2; + + private static final int DEFAULT_HEIGHT = 48; // dps + private static final int TAB_MIN_WIDTH_MARGIN = 56; //dps + private static final int FIXED_WRAP_GUTTER_MIN = 16; //dps + private static final int MOTION_NON_ADJACENT_OFFSET = 24; + + private static final int ANIMATION_DURATION = 300; + + /** + * Scrollable tabs display a subset of tabs at any given moment, and can contain longer tab + * labels and a larger number of tabs. They are best used for browsing contexts in touch + * interfaces when users don’t need to directly compare the tab labels. + * + * @see #setTabMode(int) + * @see #getTabMode() + */ + public static final int MODE_SCROLLABLE = 0; + + /** + * Fixed tabs display all tabs concurrently and are best used with content that benefits from + * quick pivots between tabs. The maximum number of tabs is limited by the view’s width. + * Fixed tabs have equal width, based on the widest tab label. + * + * @see #setTabMode(int) + * @see #getTabMode() + */ + public static final int MODE_FIXED = 1; + + /** + * @hide + */ + @IntDef(value = {MODE_SCROLLABLE, MODE_FIXED}) + @Retention(RetentionPolicy.SOURCE) + public @interface Mode {} + + /** + * Gravity used to fill the {@link VerticalTabLayout} as much as possible. This option only takes effect + * when used with {@link #MODE_FIXED}. + * + * @see #setTabGravity(int) + * @see #getTabGravity() + */ + public static final int GRAVITY_FILL = 0; + + /** + * Gravity used to lay out the tabs in the center of the {@link VerticalTabLayout}. + * + * @see #setTabGravity(int) + * @see #getTabGravity() + */ + public static final int GRAVITY_CENTER = 1; + + /** + * @hide + */ + @IntDef(flag = true, value = {GRAVITY_FILL, GRAVITY_CENTER}) + @Retention(RetentionPolicy.SOURCE) + public @interface TabGravity {} + + /** + * Callback interface invoked when a tab's selection state changes. + */ + public interface OnTabSelectedListener { + + /** + * Called when a tab enters the selected state. + * + * @param tab The tab that was selected + */ + public void onTabSelected(Tab tab); + + /** + * Called when a tab exits the selected state. + * + * @param tab The tab that was unselected + */ + public void onTabUnselected(Tab tab); + + /** + * Called when a tab that is already selected is chosen again by the user. Some applications + * may use this action to return to the top level of a category. + * + * @param tab The tab that was reselected. + */ + public void onTabReselected(Tab tab); + } + + private final ArrayList mTabs = new ArrayList<>(); + private Tab mSelectedTab; + + private final SlidingTabStrip mTabStrip; + + private int mTabPaddingStart; + private int mTabPaddingTop; + private int mTabPaddingEnd; + private int mTabPaddingBottom; + + private int mTabTextAppearance; + private ColorStateList mTabTextColors; + + private final int mTabBackgroundResId; + + private final int mTabMinWidth; + private int mTabMaxWidth = Integer.MAX_VALUE; + private final int mRequestedTabMaxWidth; + + private int mContentInsetStart; + + private int mTabGravity; + private int mMode; + + private OnTabSelectedListener mOnTabSelectedListener; + private View.OnClickListener mTabClickListener; + + private ValueAnimatorCompat mScrollAnimator; + private ValueAnimatorCompat mIndicatorAnimator; + + public VerticalTabLayout(Context context) { + this(context, null); + } + + public VerticalTabLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public VerticalTabLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + // Disable the Scroll Bar + setHorizontalScrollBarEnabled(false); + // Set us to fill the View port + setFillViewport(true); + + // Add the TabStrip + mTabStrip = new SlidingTabStrip(context); + addView(mTabStrip, LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout, + defStyleAttr, R.style.Widget_Design_TabLayout); + + mTabStrip.setSelectedIndicatorHeight( + a.getDimensionPixelSize(R.styleable.TabLayout_tabIndicatorHeight, 0)); + mTabStrip.setSelectedIndicatorColor(a.getColor(R.styleable.TabLayout_tabIndicatorColor, 0)); + + mTabTextAppearance = a.getResourceId(R.styleable.TabLayout_tabTextAppearance, + R.style.TextAppearance_Design_Tab); + + mTabPaddingStart = mTabPaddingTop = mTabPaddingEnd = mTabPaddingBottom = a + .getDimensionPixelSize(R.styleable.TabLayout_tabPadding, 0); + mTabPaddingStart = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingStart, + mTabPaddingStart); + mTabPaddingTop = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingTop, + mTabPaddingTop); + mTabPaddingEnd = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingEnd, + mTabPaddingEnd); + mTabPaddingBottom = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingBottom, + mTabPaddingBottom); + + // Text colors come from the text appearance first + mTabTextColors = loadTextColorFromTextAppearance(mTabTextAppearance); + + if (a.hasValue(R.styleable.TabLayout_tabTextColor)) { + // If we have an explicit text color set, use it instead + mTabTextColors = a.getColorStateList(R.styleable.TabLayout_tabTextColor); + } + + if (a.hasValue(R.styleable.TabLayout_tabSelectedTextColor)) { + // We have an explicit selected text color set, so we need to make merge it with the + // current colors. This is exposed so that developers can use theme attributes to set + // this (theme attrs in ColorStateLists are Lollipop+) + final int selected = a.getColor(R.styleable.TabLayout_tabSelectedTextColor, 0); + mTabTextColors = createColorStateList(mTabTextColors.getDefaultColor(), selected); + } + + mTabMinWidth = a.getDimensionPixelSize(R.styleable.TabLayout_tabMinWidth, 0); + mRequestedTabMaxWidth = a.getDimensionPixelSize(R.styleable.TabLayout_tabMaxWidth, 0); + mTabBackgroundResId = a.getResourceId(R.styleable.TabLayout_tabBackground, 0); + mContentInsetStart = a.getDimensionPixelSize(R.styleable.TabLayout_tabContentStart, 0); + mMode = a.getInt(R.styleable.TabLayout_tabMode, MODE_FIXED); + mTabGravity = a.getInt(R.styleable.TabLayout_tabGravity, GRAVITY_FILL); + a.recycle(); + + // Now apply the tab mode and gravity + applyModeAndGravity(); + } + + /** + * Sets the tab indicator's color for the currently selected tab. + * + * @param color color to use for the indicator + */ + public void setSelectedTabIndicatorColor(@ColorInt int color) { + mTabStrip.setSelectedIndicatorColor(color); + } + + /** + * Sets the tab indicator's height for the currently selected tab. + * + * @param height height to use for the indicator in pixels + */ + public void setSelectedTabIndicatorHeight(int height) { + mTabStrip.setSelectedIndicatorHeight(height); + } + + /** + * Set the scroll position of the tabs. This is useful for when the tabs are being displayed as + * part of a scrolling container such as {@link android.support.v4.view.ViewPager}. + *

+ * Calling this method does not update the selected tab, it is only used for drawing purposes. + * + * @param position current scroll position + * @param positionOffset Value from [0, 1) indicating the offset from {@code position}. + * @param updateSelectedText Whether to update the text's selected state. + */ + public void setScrollPosition(int position, float positionOffset, boolean updateSelectedText) { + if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) { + return; + } + if (position < 0 || position >= mTabStrip.getChildCount()) { + return; + } + + // Set the indicator position and update the scroll to match + mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset); + scrollTo(calculateScrollXForTab(position, positionOffset), 0); + + // Update the 'selected state' view as we scroll + if (updateSelectedText) { + setSelectedTabView(Math.round(position + positionOffset)); + } + } + + /** + * Add a tab to this layout. The tab will be added at the end of the list. + * If this is the first tab to be added it will become the selected tab. + * + * @param tab Tab to add + */ + public void addTab(@NonNull Tab tab) { + addTab(tab, mTabs.isEmpty()); + } + + /** + * Add a tab to this layout. The tab will be inserted at position. + * If this is the first tab to be added it will become the selected tab. + * + * @param tab The tab to add + * @param position The new position of the tab + */ + public void addTab(@NonNull Tab tab, int position) { + addTab(tab, position, mTabs.isEmpty()); + } + + /** + * Add a tab to this layout. The tab will be added at the end of the list. + * + * @param tab Tab to add + * @param setSelected True if the added tab should become the selected tab. + */ + public void addTab(@NonNull Tab tab, boolean setSelected) { + if (tab.mParent != this) { + throw new IllegalArgumentException("Tab belongs to a different VerticalTabLayout."); + } + + addTabView(tab, setSelected); + configureTab(tab, mTabs.size()); + if (setSelected) { + tab.select(); + } + } + + /** + * Add a tab to this layout. The tab will be inserted at position. + * + * @param tab The tab to add + * @param position The new position of the tab + * @param setSelected True if the added tab should become the selected tab. + */ + public void addTab(@NonNull Tab tab, int position, boolean setSelected) { + if (tab.mParent != this) { + throw new IllegalArgumentException("Tab belongs to a different VerticalTabLayout."); + } + + addTabView(tab, position, setSelected); + configureTab(tab, position); + if (setSelected) { + tab.select(); + } + } + + /** + * Set the {@link android.support.design.widget.VerticalTabLayout.OnTabSelectedListener} that will + * handle switching to and from tabs. + * + * @param onTabSelectedListener Listener to handle tab selection events + */ + public void setOnTabSelectedListener(OnTabSelectedListener onTabSelectedListener) { + mOnTabSelectedListener = onTabSelectedListener; + } + + /** + * Create and return a new {@link Tab}. You need to manually add this using + * {@link #addTab(Tab)} or a related method. + * + * @return A new Tab + * @see #addTab(Tab) + */ + @NonNull + public Tab newTab() { + return new Tab(this); + } + + /** + * Returns the number of tabs currently registered with the action bar. + * + * @return Tab count + */ + public int getTabCount() { + return mTabs.size(); + } + + /** + * Returns the tab at the specified index. + */ + @Nullable + public Tab getTabAt(int index) { + return mTabs.get(index); + } + + /** + * Returns the position of the current selected tab. + * + * @return selected tab position, or {@code -1} if there isn't a selected tab. + */ + public int getSelectedTabPosition() { + return mSelectedTab != null ? mSelectedTab.getPosition() : -1; + } + + /** + * Remove a tab from the layout. If the removed tab was selected it will be deselected + * and another tab will be selected if present. + * + * @param tab The tab to remove + */ + public void removeTab(Tab tab) { + if (tab.mParent != this) { + throw new IllegalArgumentException("Tab does not belong to this VerticalTabLayout."); + } + + removeTabAt(tab.getPosition()); + } + + /** + * Remove a tab from the layout. If the removed tab was selected it will be deselected + * and another tab will be selected if present. + * + * @param position Position of the tab to remove + */ + public void removeTabAt(int position) { + final int selectedTabPosition = mSelectedTab != null ? mSelectedTab.getPosition() : 0; + removeTabViewAt(position); + + Tab removedTab = mTabs.remove(position); + if (removedTab != null) { + removedTab.setPosition(Tab.INVALID_POSITION); + } + + final int newTabCount = mTabs.size(); + for (int i = position; i < newTabCount; i++) { + mTabs.get(i).setPosition(i); + } + + if (selectedTabPosition == position) { + selectTab(mTabs.isEmpty() ? null : mTabs.get(Math.max(0, position - 1))); + } + } + + /** + * Remove all tabs from the action bar and deselect the current tab. + */ + public void removeAllTabs() { + // Remove all the views + mTabStrip.removeAllViews(); + + for (Iterator i = mTabs.iterator(); i.hasNext(); ) { + Tab tab = i.next(); + tab.setPosition(Tab.INVALID_POSITION); + i.remove(); + } + + mSelectedTab = null; + } + + /** + * Set the behavior mode for the Tabs in this layout. The valid input options are: + *

    + *
  • {@link #MODE_FIXED}: Fixed tabs display all tabs concurrently and are best used + * with content that benefits from quick pivots between tabs.
  • + *
  • {@link #MODE_SCROLLABLE}: Scrollable tabs display a subset of tabs at any given moment, + * and can contain longer tab labels and a larger number of tabs. They are best used for + * browsing contexts in touch interfaces when users don’t need to directly compare the tab + * labels. This mode is commonly used with a {@link android.support.v4.view.ViewPager}.
  • + *
+ * + * @param mode one of {@link #MODE_FIXED} or {@link #MODE_SCROLLABLE}. + */ + public void setTabMode(@Mode int mode) { + if (mode != mMode) { + mMode = mode; + applyModeAndGravity(); + } + } + + /** + * Returns the current mode used by this {@link VerticalTabLayout}. + * + * @see #setTabMode(int) + */ + @Mode + public int getTabMode() { + return mMode; + } + + /** + * Set the gravity to use when laying out the tabs. + * + * @param gravity one of {@link #GRAVITY_CENTER} or {@link #GRAVITY_FILL}. + */ + public void setTabGravity(@TabGravity int gravity) { + if (mTabGravity != gravity) { + mTabGravity = gravity; + applyModeAndGravity(); + } + } + + /** + * The current gravity used for laying out tabs. + * + * @return one of {@link #GRAVITY_CENTER} or {@link #GRAVITY_FILL}. + */ + @TabGravity + public int getTabGravity() { + return mTabGravity; + } + + /** + * Sets the text colors for the different states (normal, selected) used for the tabs. + */ + public void setTabTextColors(@Nullable ColorStateList textColor) { + if (mTabTextColors != textColor) { + mTabTextColors = textColor; + updateAllTabs(); + } + } + + /** + * Gets the text colors for the different states (normal, selected) used for the tabs. + */ + @Nullable + public ColorStateList getTabTextColors() { + return mTabTextColors; + } + + /** + * Sets the text colors for the different states (normal, selected) used for the tabs. + */ + public void setTabTextColors(int normalColor, int selectedColor) { + setTabTextColors(createColorStateList(normalColor, selectedColor)); + } + + /** + * The one-stop shop for setting up this {@link VerticalTabLayout} with a {@link ViewPager}. + * + *

This method will: + *

    + *
  • Add a {@link ViewPager.OnPageChangeListener} that will forward events to + * this VerticalTabLayout.
  • + *
  • Populate the VerticalTabLayout's tabs from the ViewPager's {@link PagerAdapter}.
  • + *
  • Set our {@link VerticalTabLayout.OnTabSelectedListener} which will forward + * selected events to the ViewPager
  • + *
+ *

+ * + * @see #setTabsFromPagerAdapter(PagerAdapter) + * @see VerticalTabLayoutOnPageChangeListener + * @see ViewPagerOnTabSelectedListener + */ + public void setupWithViewPager(@NonNull ViewPager viewPager) { + final PagerAdapter adapter = viewPager.getAdapter(); + if (adapter == null) { + throw new IllegalArgumentException("ViewPager does not have a PagerAdapter set"); + } + + // First we'll add Tabs, using the adapter's page titles + setTabsFromPagerAdapter(adapter); + + // Now we'll add our page change listener to the ViewPager + viewPager.addOnPageChangeListener(new VerticalTabLayoutOnPageChangeListener(this)); + + // Now we'll add a tab selected listener to set ViewPager's current item + setOnTabSelectedListener(new ViewPagerOnTabSelectedListener(viewPager)); + + // Make sure we reflect the currently set ViewPager item + if (mSelectedTab == null || (mSelectedTab.getPosition() != viewPager.getCurrentItem())) { + getTabAt(viewPager.getCurrentItem()).select(); + } + } + + /** + * Populate our tab content from the given {@link PagerAdapter}. + *

+ * Any existing tabs will be removed first. Each tab will have it's text set to the value + * returned from {@link PagerAdapter#getPageTitle(int)} + *

+ * + * @param adapter the adapter to populate from + */ + public void setTabsFromPagerAdapter(@NonNull PagerAdapter adapter) { + removeAllTabs(); + for (int i = 0, count = adapter.getCount(); i < count; i++) { + addTab(newTab().setText(adapter.getPageTitle(i))); + } + } + + private void updateAllTabs() { + for (int i = 0, z = mTabStrip.getChildCount(); i < z; i++) { + updateTab(i); + } + } + + private TabView createTabView(Tab tab) { + final TabView tabView = new TabView(getContext(), tab); + tabView.setFocusable(true); + + if (mTabClickListener == null) { + mTabClickListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + TabView tabView = (TabView) view; + tabView.getTab().select(); + } + }; + } + tabView.setOnClickListener(mTabClickListener); + return tabView; + } + + private void configureTab(Tab tab, int position) { + tab.setPosition(position); + mTabs.add(position, tab); + + final int count = mTabs.size(); + for (int i = position + 1; i < count; i++) { + mTabs.get(i).setPosition(i); + } + } + + private void updateTab(int position) { + final TabView view = (TabView) mTabStrip.getChildAt(position); + if (view != null) { + view.update(); + } + } + + private void addTabView(Tab tab, boolean setSelected) { + final TabView tabView = createTabView(tab); + mTabStrip.addView(tabView, createLayoutParamsForTabs()); + if (setSelected) { + tabView.setSelected(true); + } + } + + private void addTabView(Tab tab, int position, boolean setSelected) { + final TabView tabView = createTabView(tab); + mTabStrip.addView(tabView, position, createLayoutParamsForTabs()); + if (setSelected) { + tabView.setSelected(true); + } + } + + private LinearLayout.LayoutParams createLayoutParamsForTabs() { + final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); + updateTabViewLayoutParams(lp); + return lp; + } + + private void updateTabViewLayoutParams(LinearLayout.LayoutParams lp) { + if (mMode == MODE_FIXED && mTabGravity == GRAVITY_FILL) { + lp.width = 0; + lp.weight = 1; + } else { + lp.width = LinearLayout.LayoutParams.WRAP_CONTENT; + lp.weight = 0; + } + } + + private int dpToPx(int dps) { + return Math.round(getResources().getDisplayMetrics().density * dps); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // If we have a MeasureSpec which allows us to decide our height, try and use the default + // height + final int idealHeight = dpToPx(DEFAULT_HEIGHT) + getPaddingTop() + getPaddingBottom(); + switch (MeasureSpec.getMode(heightMeasureSpec)) { + case MeasureSpec.AT_MOST: + heightMeasureSpec = MeasureSpec.makeMeasureSpec( + Math.min(idealHeight, MeasureSpec.getSize(heightMeasureSpec)), + MeasureSpec.EXACTLY); + break; + case MeasureSpec.UNSPECIFIED: + heightMeasureSpec = MeasureSpec.makeMeasureSpec(idealHeight, MeasureSpec.EXACTLY); + break; + } + + // Now super measure itself using the (possibly) modified height spec + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (mMode == MODE_FIXED && getChildCount() == 1) { + // If we're in fixed mode then we need to make the tab strip is the same width as us + // so we don't scroll + final View child = getChildAt(0); + final int width = getMeasuredWidth(); + + if (child.getMeasuredWidth() > width) { + // If the child is wider than us, re-measure it with a widthSpec set to exact our + // measure width + int childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, getPaddingTop() + + getPaddingBottom(), child.getLayoutParams().height); + int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY); + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + } + + // Now update the tab max width. We do it here as the default tab min width is + // layout width - 56dp + int maxTabWidth = mRequestedTabMaxWidth; + final int defaultTabMaxWidth = getMeasuredWidth() - dpToPx(TAB_MIN_WIDTH_MARGIN); + if (maxTabWidth == 0 || maxTabWidth > defaultTabMaxWidth) { + // If the request tab max width is 0, or larger than our default, use the default + maxTabWidth = defaultTabMaxWidth; + } + + if (mTabMaxWidth != maxTabWidth) { + // If the tab max width has changed, re-measure + mTabMaxWidth = maxTabWidth; + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + private void removeTabViewAt(int position) { + mTabStrip.removeViewAt(position); + requestLayout(); + } + + private void animateToTab(int newPosition) { + if (newPosition == Tab.INVALID_POSITION) { + return; + } + + if (getWindowToken() == null || !ViewCompat.isLaidOut(this) + || mTabStrip.childrenNeedLayout()) { + // If we don't have a window token, or we haven't been laid out yet just draw the new + // position now + setScrollPosition(newPosition, 0f, true); + return; + } + + final int startScrollX = getScrollX(); + final int targetScrollX = calculateScrollXForTab(newPosition, 0); + + if (startScrollX != targetScrollX) { + if (mScrollAnimator == null) { + mScrollAnimator = ViewUtils.createAnimator(); + mScrollAnimator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR); + mScrollAnimator.setDuration(ANIMATION_DURATION); + mScrollAnimator.setUpdateListener(new ValueAnimatorCompat.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimatorCompat animator) { + scrollTo(animator.getAnimatedIntValue(), 0); + } + }); + } + + mScrollAnimator.setIntValues(startScrollX, targetScrollX); + mScrollAnimator.start(); + } + + // Now animate the indicator + mTabStrip.animateIndicatorToPosition(newPosition, ANIMATION_DURATION); + } + + private void setSelectedTabView(int position) { + final int tabCount = mTabStrip.getChildCount(); + for (int i = 0; i < tabCount; i++) { + final View child = mTabStrip.getChildAt(i); + child.setSelected(i == position); + } + } + + void selectTab(Tab tab) { + selectTab(tab, true); + } + + void selectTab(Tab tab, boolean updateIndicator) { + if (mSelectedTab == tab) { + if (mSelectedTab != null) { + if (mOnTabSelectedListener != null) { + mOnTabSelectedListener.onTabReselected(mSelectedTab); + } + animateToTab(tab.getPosition()); + } + } else { + final int newPosition = tab != null ? tab.getPosition() : Tab.INVALID_POSITION; + setSelectedTabView(newPosition); + if (updateIndicator) { + if ((mSelectedTab == null || mSelectedTab.getPosition() == Tab.INVALID_POSITION) + && newPosition != Tab.INVALID_POSITION) { + // If we don't currently have a tab, just draw the indicator + setScrollPosition(newPosition, 0f, true); + } else { + animateToTab(newPosition); + } + } + if (mSelectedTab != null && mOnTabSelectedListener != null) { + mOnTabSelectedListener.onTabUnselected(mSelectedTab); + } + mSelectedTab = tab; + if (mSelectedTab != null && mOnTabSelectedListener != null) { + mOnTabSelectedListener.onTabSelected(mSelectedTab); + } + } + } + + private int calculateScrollXForTab(int position, float positionOffset) { + if (mMode == MODE_SCROLLABLE) { + final View selectedChild = mTabStrip.getChildAt(position); + final View nextChild = position + 1 < mTabStrip.getChildCount() + ? mTabStrip.getChildAt(position + 1) + : null; + final int selectedWidth = selectedChild != null ? selectedChild.getWidth() : 0; + final int nextWidth = nextChild != null ? nextChild.getWidth() : 0; + + return selectedChild.getLeft() + + ((int) ((selectedWidth + nextWidth) * positionOffset * 0.5f)) + + (selectedChild.getWidth() / 2) + - (getWidth() / 2); + } + return 0; + } + + private void applyModeAndGravity() { + int paddingStart = 0; + if (mMode == MODE_SCROLLABLE) { + // If we're scrollable, or fixed at start, inset using padding + paddingStart = Math.max(0, mContentInsetStart - mTabPaddingStart); + } + ViewCompat.setPaddingRelative(mTabStrip, paddingStart, 0, 0, 0); + + switch (mMode) { + case MODE_FIXED: + mTabStrip.setGravity(Gravity.CENTER_HORIZONTAL); + break; + case MODE_SCROLLABLE: + mTabStrip.setGravity(GravityCompat.START); + break; + } + + updateTabViewsLayoutParams(); + } + + private void updateTabViewsLayoutParams() { + for (int i = 0; i < mTabStrip.getChildCount(); i++) { + View child = mTabStrip.getChildAt(i); + updateTabViewLayoutParams((LinearLayout.LayoutParams) child.getLayoutParams()); + child.requestLayout(); + } + } + + /** + * A tab in this layout. Instances can be created via {@link #newTab()}. + */ + public static final class Tab { + + /** + * An invalid position for a tab. + * + * @see #getPosition() + */ + public static final int INVALID_POSITION = -1; + + private Object mTag; + private Drawable mIcon; + private CharSequence mText; + private CharSequence mContentDesc; + private int mPosition = INVALID_POSITION; + private View mCustomView; + + private final VerticalTabLayout mParent; + + Tab(VerticalTabLayout parent) { + mParent = parent; + } + + /** + * @return This Tab's tag object. + */ + @Nullable + public Object getTag() { + return mTag; + } + + /** + * Give this Tab an arbitrary object to hold for later use. + * + * @param tag Object to store + * @return The current instance for call chaining + */ + @NonNull + public Tab setTag(@Nullable Object tag) { + mTag = tag; + return this; + } + + + /** + * Returns the custom view used for this tab. + * + * @see #setCustomView(View) + * @see #setCustomView(int) + */ + @Nullable + public View getCustomView() { + return mCustomView; + } + + /** + * Set a custom view to be used for this tab. + *

+ * If the provided view contains a {@link TextView} with an ID of + * {@link android.R.id#text1} then that will be updated with the value given + * to {@link #setText(CharSequence)}. Similarly, if this layout contains an + * {@link ImageView} with ID {@link android.R.id#icon} then it will be updated with + * the value given to {@link #setIcon(Drawable)}. + *

+ * + * @param view Custom view to be used as a tab. + * @return The current instance for call chaining + */ + @NonNull + public Tab setCustomView(@Nullable View view) { + mCustomView = view; + if (mPosition >= 0) { + mParent.updateTab(mPosition); + } + return this; + } + + /** + * Set a custom view to be used for this tab. + *

+ * If the inflated layout contains a {@link TextView} with an ID of + * {@link android.R.id#text1} then that will be updated with the value given + * to {@link #setText(CharSequence)}. Similarly, if this layout contains an + * {@link ImageView} with ID {@link android.R.id#icon} then it will be updated with + * the value given to {@link #setIcon(Drawable)}. + *

+ * + * @param layoutResId A layout resource to inflate and use as a custom tab view + * @return The current instance for call chaining + */ + @NonNull + public Tab setCustomView(@LayoutRes int layoutResId) { + return setCustomView( + LayoutInflater.from(mParent.getContext()).inflate(layoutResId, null)); + } + + /** + * Return the icon associated with this tab. + * + * @return The tab's icon + */ + @Nullable + public Drawable getIcon() { + return mIcon; + } + + /** + * Return the current position of this tab in the action bar. + * + * @return Current position, or {@link #INVALID_POSITION} if this tab is not currently in + * the action bar. + */ + public int getPosition() { + return mPosition; + } + + void setPosition(int position) { + mPosition = position; + } + + /** + * Return the text of this tab. + * + * @return The tab's text + */ + @Nullable + public CharSequence getText() { + return mText; + } + + /** + * Set the icon displayed on this tab. + * + * @param icon The drawable to use as an icon + * @return The current instance for call chaining + */ + @NonNull + public Tab setIcon(@Nullable Drawable icon) { + mIcon = icon; + if (mPosition >= 0) { + mParent.updateTab(mPosition); + } + return this; + } + + /** + * Set the icon displayed on this tab. + * + * @param resId A resource ID referring to the icon that should be displayed + * @return The current instance for call chaining + */ + @NonNull + public Tab setIcon(@DrawableRes int resId) { + return setIcon(TintManager.getDrawable(mParent.getContext(), resId)); + } + + /** + * Set the text displayed on this tab. Text may be truncated if there is not room to display + * the entire string. + * + * @param text The text to display + * @return The current instance for call chaining + */ + @NonNull + public Tab setText(@Nullable CharSequence text) { + mText = text; + if (mPosition >= 0) { + mParent.updateTab(mPosition); + } + return this; + } + + /** + * Set the text displayed on this tab. Text may be truncated if there is not room to display + * the entire string. + * + * @param resId A resource ID referring to the text that should be displayed + * @return The current instance for call chaining + */ + @NonNull + public Tab setText(@StringRes int resId) { + return setText(mParent.getResources().getText(resId)); + } + + /** + * Select this tab. Only valid if the tab has been added to the action bar. + */ + public void select() { + mParent.selectTab(this); + } + + /** + * Returns true if this tab is currently selected. + */ + public boolean isSelected() { + return mParent.getSelectedTabPosition() == mPosition; + } + + /** + * Set a description of this tab's content for use in accessibility support. If no content + * description is provided the title will be used. + * + * @param resId A resource ID referring to the description text + * @return The current instance for call chaining + * @see #setContentDescription(CharSequence) + * @see #getContentDescription() + */ + @NonNull + public Tab setContentDescription(@StringRes int resId) { + return setContentDescription(mParent.getResources().getText(resId)); + } + + /** + * Set a description of this tab's content for use in accessibility support. If no content + * description is provided the title will be used. + * + * @param contentDesc Description of this tab's content + * @return The current instance for call chaining + * @see #setContentDescription(int) + * @see #getContentDescription() + */ + @NonNull + public Tab setContentDescription(@Nullable CharSequence contentDesc) { + mContentDesc = contentDesc; + if (mPosition >= 0) { + mParent.updateTab(mPosition); + } + return this; + } + + /** + * Gets a brief description of this tab's content for use in accessibility support. + * + * @return Description of this tab's content + * @see #setContentDescription(CharSequence) + * @see #setContentDescription(int) + */ + @Nullable + public CharSequence getContentDescription() { + return mContentDesc; + } + } + + class TabView extends LinearLayout implements OnLongClickListener { + private final Tab mTab; + private TextView mTextView; + private ImageView mIconView; + + private View mCustomView; + private TextView mCustomTextView; + private ImageView mCustomIconView; + + public TabView(Context context, Tab tab) { + super(context); + mTab = tab; + if (mTabBackgroundResId != 0) { + setBackgroundDrawable(TintManager.getDrawable(context, mTabBackgroundResId)); + } + ViewCompat.setPaddingRelative(this, mTabPaddingStart, mTabPaddingTop, + mTabPaddingEnd, mTabPaddingBottom); + setGravity(Gravity.CENTER); + update(); + } + + @Override + public void setSelected(boolean selected) { + final boolean changed = (isSelected() != selected); + super.setSelected(selected); + if (changed && selected) { + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); + + if (mTextView != null) { + mTextView.setSelected(selected); + } + if (mIconView != null) { + mIconView.setSelected(selected); + } + } + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + // This view masquerades as an action bar tab. + event.setClassName(ActionBar.Tab.class.getName()); + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + // This view masquerades as an action bar tab. + info.setClassName(ActionBar.Tab.class.getName()); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + final int measuredWidth = getMeasuredWidth(); + if (measuredWidth < mTabMinWidth || measuredWidth > mTabMaxWidth) { + // Re-measure if we are outside our min or max width + widthMeasureSpec = MeasureSpec.makeMeasureSpec( + MathUtils.constrain(measuredWidth, mTabMinWidth, mTabMaxWidth), + MeasureSpec.EXACTLY); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + final void update() { + final Tab tab = mTab; + final View custom = tab.getCustomView(); + if (custom != null) { + final ViewParent customParent = custom.getParent(); + if (customParent != this) { + if (customParent != null) { + ((ViewGroup) customParent).removeView(custom); + } + addView(custom); + } + mCustomView = custom; + if (mTextView != null) { + mTextView.setVisibility(GONE); + } + if (mIconView != null) { + mIconView.setVisibility(GONE); + mIconView.setImageDrawable(null); + } + + mCustomTextView = (TextView) custom.findViewById(android.R.id.text1); + mCustomIconView = (ImageView) custom.findViewById(android.R.id.icon); + } else { + // We do not have a custom view. Remove one if it already exists + if (mCustomView != null) { + removeView(mCustomView); + mCustomView = null; + } + mCustomTextView = null; + mCustomIconView = null; + } + + if (mCustomView == null) { + // If there isn't a custom view, we'll us our own in-built layouts + if (mIconView == null) { + ImageView iconView = (ImageView) LayoutInflater.from(getContext()) + .inflate(R.layout.design_layout_tab_icon, this, false); + addView(iconView, 0); + mIconView = iconView; + } + if (mTextView == null) { + TextView textView = (TextView) LayoutInflater.from(getContext()) + .inflate(R.layout.design_layout_tab_text, this, false); + addView(textView); + mTextView = textView; + } + mTextView.setTextAppearance(getContext(), mTabTextAppearance); + if (mTabTextColors != null) { + mTextView.setTextColor(mTabTextColors); + } + updateTextAndIcon(tab, mTextView, mIconView); + } else { + // Else, we'll see if there is a TextView or ImageView present and update them + if (mCustomTextView != null || mCustomIconView != null) { + updateTextAndIcon(tab, mCustomTextView, mCustomIconView); + } + } + } + + private void updateTextAndIcon(Tab tab, TextView textView, ImageView iconView) { + final Drawable icon = tab.getIcon(); + final CharSequence text = tab.getText(); + + if (iconView != null) { + if (icon != null) { + iconView.setImageDrawable(icon); + iconView.setVisibility(VISIBLE); + setVisibility(VISIBLE); + } else { + iconView.setVisibility(GONE); + iconView.setImageDrawable(null); + } + iconView.setContentDescription(tab.getContentDescription()); + } + + final boolean hasText = !TextUtils.isEmpty(text); + if (textView != null) { + if (hasText) { + textView.setText(text); + textView.setContentDescription(tab.getContentDescription()); + textView.setVisibility(VISIBLE); + setVisibility(VISIBLE); + } else { + textView.setVisibility(GONE); + textView.setText(null); + } + } + + if (!hasText && !TextUtils.isEmpty(tab.getContentDescription())) { + setOnLongClickListener(this); + } else { + setOnLongClickListener(null); + setLongClickable(false); + } + } + + @Override + public boolean onLongClick(View v) { + final int[] screenPos = new int[2]; + getLocationOnScreen(screenPos); + + final Context context = getContext(); + final int width = getWidth(); + final int height = getHeight(); + final int screenWidth = context.getResources().getDisplayMetrics().widthPixels; + + Toast cheatSheet = Toast.makeText(context, mTab.getContentDescription(), + Toast.LENGTH_SHORT); + // Show under the tab + cheatSheet.setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL, + (screenPos[0] + width / 2) - screenWidth / 2, height); + + cheatSheet.show(); + return true; + } + + public Tab getTab() { + return mTab; + } + } + + private class SlidingTabStrip extends LinearLayout { + private int mSelectedIndicatorHeight; + private final Paint mSelectedIndicatorPaint; + + private int mSelectedPosition = -1; + private float mSelectionOffset; + + private int mIndicatorLeft = -1; + private int mIndicatorRight = -1; + + SlidingTabStrip(Context context) { + super(context); + setWillNotDraw(false); + mSelectedIndicatorPaint = new Paint(); + } + + void setSelectedIndicatorColor(int color) { + mSelectedIndicatorPaint.setColor(color); + ViewCompat.postInvalidateOnAnimation(this); + } + + void setSelectedIndicatorHeight(int height) { + mSelectedIndicatorHeight = height; + ViewCompat.postInvalidateOnAnimation(this); + } + + boolean childrenNeedLayout() { + for (int i = 0, z = getChildCount(); i < z; i++) { + final View child = getChildAt(i); + if (child.getWidth() <= 0) { + return true; + } + } + return false; + } + + void setIndicatorPositionFromTabPosition(int position, float positionOffset) { + if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) { + return; + } + mSelectedPosition = position; + mSelectionOffset = positionOffset; + updateIndicatorPosition(); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) { + // HorizontalScrollView will first measure use with UNSPECIFIED, and then with + // EXACTLY. Ignore the first call since anything we do will be overwritten anyway + return; + } + + if (mMode == MODE_FIXED && mTabGravity == GRAVITY_CENTER) { + final int count = getChildCount(); + + final int unspecifiedSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + + // First we'll find the largest tab + int largestTabWidth = 0; + for (int i = 0, z = count; i < z; i++) { + final View child = getChildAt(i); + child.measure(unspecifiedSpec, heightMeasureSpec); + largestTabWidth = Math.max(largestTabWidth, child.getMeasuredWidth()); + } + + if (largestTabWidth <= 0) { + // If we don't have a largest child yet, skip until the next measure pass + return; + } + + final int gutter = dpToPx(FIXED_WRAP_GUTTER_MIN); + if (largestTabWidth * count <= getMeasuredWidth() - gutter * 2) { + // If the tabs fit within our width minus gutters, we will set all tabs to have + // the same width + for (int i = 0; i < count; i++) { + final View child = getChildAt(i); + final LinearLayout.LayoutParams lp = (LayoutParams) child.getLayoutParams(); + lp.width = largestTabWidth; + lp.weight = 0; + } + } else { + // If the tabs will wrap to be larger than the width minus gutters, we need + // to switch to GRAVITY_FILL + mTabGravity = GRAVITY_FILL; + updateTabViewsLayoutParams(); + } + + // Now re-measure after our changes + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + // If we've been layed out, update the indicator position + updateIndicatorPosition(); + } + + private void updateIndicatorPosition() { + final View selectedTitle = getChildAt(mSelectedPosition); + int left, right; + + if (selectedTitle != null && selectedTitle.getWidth() > 0) { + left = selectedTitle.getLeft(); + right = selectedTitle.getRight(); + + if (mSelectionOffset > 0f && mSelectedPosition < getChildCount() - 1) { + // Draw the selection partway between the tabs + View nextTitle = getChildAt(mSelectedPosition + 1); + left = (int) (mSelectionOffset * nextTitle.getLeft() + + (1.0f - mSelectionOffset) * left); + right = (int) (mSelectionOffset * nextTitle.getRight() + + (1.0f - mSelectionOffset) * right); + } + } else { + left = right = -1; + } + + setIndicatorPosition(left, right); + } + + private void setIndicatorPosition(int left, int right) { + if (left != mIndicatorLeft || right != mIndicatorRight) { + // If the indicator's left/right has changed, invalidate + mIndicatorLeft = left; + mIndicatorRight = right; + ViewCompat.postInvalidateOnAnimation(this); + } + } + + void animateIndicatorToPosition(final int position, int duration) { + final boolean isRtl = ViewCompat.getLayoutDirection(this) + == ViewCompat.LAYOUT_DIRECTION_RTL; + + final View targetView = getChildAt(position); + final int targetLeft = targetView.getLeft(); + final int targetRight = targetView.getRight(); + final int startLeft; + final int startRight; + + if (Math.abs(position - mSelectedPosition) <= 1) { + // If the views are adjacent, we'll animate from edge-to-edge + startLeft = mIndicatorLeft; + startRight = mIndicatorRight; + } else { + // Else, we'll just grow from the nearest edge + final int offset = dpToPx(MOTION_NON_ADJACENT_OFFSET); + if (position < mSelectedPosition) { + // We're going end-to-start + if (isRtl) { + startLeft = startRight = targetLeft - offset; + } else { + startLeft = startRight = targetRight + offset; + } + } else { + // We're going start-to-end + if (isRtl) { + startLeft = startRight = targetRight + offset; + } else { + startLeft = startRight = targetLeft - offset; + } + } + } + + if (startLeft != targetLeft || startRight != targetRight) { + ValueAnimatorCompat animator = mIndicatorAnimator = ViewUtils.createAnimator(); + animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR); + animator.setDuration(duration); + animator.setFloatValues(0, 1); + animator.setUpdateListener(new ValueAnimatorCompat.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimatorCompat animator) { + final float fraction = animator.getAnimatedFraction(); + setIndicatorPosition( + AnimationUtils.lerp(startLeft, targetLeft, fraction), + AnimationUtils.lerp(startRight, targetRight, fraction)); + } + }); + animator.setListener(new ValueAnimatorCompat.AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(ValueAnimatorCompat animator) { + mSelectedPosition = position; + mSelectionOffset = 0f; + } + + @Override + public void onAnimationCancel(ValueAnimatorCompat animator) { + mSelectedPosition = position; + mSelectionOffset = 0f; + } + }); + animator.start(); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + // Thick colored underline below the current selection + if (mIndicatorLeft >= 0 && mIndicatorRight > mIndicatorLeft) { + canvas.drawRect(mIndicatorLeft, getHeight() - mSelectedIndicatorHeight, + mIndicatorRight, getHeight(), mSelectedIndicatorPaint); + } + } + } + + private static ColorStateList createColorStateList(int defaultColor, int selectedColor) { + final int[][] states = new int[2][]; + final int[] colors = new int[2]; + int i = 0; + + states[i] = SELECTED_STATE_SET; + colors[i] = selectedColor; + i++; + + // Default enabled state + states[i] = EMPTY_STATE_SET; + colors[i] = defaultColor; + i++; + + return new ColorStateList(states, colors); + } + + private ColorStateList loadTextColorFromTextAppearance(int textAppearanceResId) { + TypedArray a = getContext().obtainStyledAttributes(textAppearanceResId, + R.styleable.TextAppearance); + try { + return a.getColorStateList(R.styleable.TextAppearance_android_textColor); + } finally { + a.recycle(); + } + } + + /** + * A {@link ViewPager.OnPageChangeListener} class which contains the + * necessary calls back to the provided {@link VerticalTabLayout} so that the tab position is + * kept in sync. + * + *

This class stores the provided VerticalTabLayout weakly, meaning that you can use + * {@link ViewPager#addOnPageChangeListener(ViewPager.OnPageChangeListener) + * addOnPageChangeListener(OnPageChangeListener)} without removing the listener and + * not cause a leak. + */ + public static class VerticalTabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener { + private final WeakReference mVerticalTabLayoutRef; + private int mPreviousScrollState; + private int mScrollState; + + public VerticalTabLayoutOnPageChangeListener(VerticalTabLayout VerticalTabLayout) { + mVerticalTabLayoutRef = new WeakReference<>(VerticalTabLayout); + } + + @Override + public void onPageScrollStateChanged(int state) { + mPreviousScrollState = mScrollState; + mScrollState = state; + } + + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + final VerticalTabLayout VerticalTabLayout = mVerticalTabLayoutRef.get(); + if (VerticalTabLayout != null) { + // Update the scroll position, only update the text selection if we're being + // dragged (or we're settling after a drag) + final boolean updateText = (mScrollState == SCROLL_STATE_DRAGGING) + || (mScrollState == SCROLL_STATE_SETTLING + && mPreviousScrollState == SCROLL_STATE_DRAGGING); + VerticalTabLayout.setScrollPosition(position, positionOffset, updateText); + } + } + + @Override + public void onPageSelected(int position) { + final VerticalTabLayout VerticalTabLayout = mVerticalTabLayoutRef.get(); + if (VerticalTabLayout != null) { + // Select the tab, only updating the indicator if we're not being dragged/settled + // (since onPageScrolled will handle that). + VerticalTabLayout.selectTab(VerticalTabLayout.getTabAt(position), + mScrollState == SCROLL_STATE_IDLE); + } + } + } + + /** + * A {@link VerticalTabLayout.OnTabSelectedListener} class which contains the necessary calls back + * to the provided {@link ViewPager} so that the tab position is kept in sync. + */ + public static class ViewPagerOnTabSelectedListener implements VerticalTabLayout.OnTabSelectedListener { + private final ViewPager mViewPager; + + public ViewPagerOnTabSelectedListener(ViewPager viewPager) { + mViewPager = viewPager; + } + + @Override + public void onTabSelected(VerticalTabLayout.Tab tab) { + mViewPager.setCurrentItem(tab.getPosition()); + } + + @Override + public void onTabUnselected(VerticalTabLayout.Tab tab) { + // No-op + } + + @Override + public void onTabReselected(VerticalTabLayout.Tab tab) { + // No-op + } + } + +}