diff --git a/mastodon/build.gradle b/mastodon/build.gradle index e17280718..3001ae797 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -29,10 +29,11 @@ android { dependencies { api 'androidx.annotation:annotation:1.3.0' implementation 'com.squareup.okhttp3:okhttp:3.14.9' - implementation 'me.grishka.litex:recyclerview:1.2.1' + implementation 'me.grishka.litex:recyclerview:1.2.1.1' implementation 'me.grishka.litex:swiperefreshlayout:1.1.0' implementation 'me.grishka.litex:browser:1.4.0' implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03' + implementation 'me.grishka.litex:viewpager:1.0.0' implementation 'me.grishka.litex:viewpager2:1.0.0' implementation 'me.grishka.appkit:appkit:1.2' implementation 'com.google.code.gson:gson:2.8.9' diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index c8de46d2a..c3bc28451 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.api; import android.net.Uri; +import android.util.Pair; import com.google.gson.reflect.TypeToken; @@ -10,6 +11,7 @@ import org.joinmastodon.android.model.BaseModel; import org.joinmastodon.android.model.Token; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -27,7 +29,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ private String path; private String method; private Object requestBody; - private Map queryParams; + private List> queryParams; Class respClass; TypeToken respTypeToken; Call okhttpCall; @@ -86,8 +88,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ protected void addQueryParameter(String key, String value){ if(queryParams==null) - queryParams=new HashMap<>(); - queryParams.put(key, value); + queryParams=new ArrayList<>(); + queryParams.add(new Pair<>(key, value)); } protected void addHeader(String key, String value){ @@ -106,8 +108,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ .authority(domain) .path(getPathPrefix()+path); if(queryParams!=null){ - for(Map.Entry param:queryParams.entrySet()){ - builder.appendQueryParameter(param.getKey(), param.getValue()); + for(Pair param:queryParams){ + builder.appendQueryParameter(param.first, param.second); } } return builder.build(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountRelationships.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountRelationships.java new file mode 100644 index 000000000..fec22bbe0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountRelationships.java @@ -0,0 +1,18 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Relationship; + +import java.util.List; + +import androidx.annotation.NonNull; + +public class GetAccountRelationships extends MastodonAPIRequest>{ + public GetAccountRelationships(@NonNull List ids){ + super(HttpMethod.GET, "/accounts/relationships", new TypeToken<>(){}); + for(String id:ids) + addQueryParameter("id[]", id); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java index 85f534ccc..4c5e29136 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountStatuses.java @@ -7,8 +7,10 @@ import org.joinmastodon.android.model.Status; import java.util.List; +import androidx.annotation.NonNull; + public class GetAccountStatuses extends MastodonAPIRequest>{ - public GetAccountStatuses(String id, String maxID, String minID, int limit){ + public GetAccountStatuses(String id, String maxID, String minID, int limit, @NonNull Filter filter){ super(HttpMethod.GET, "/accounts/"+id+"/statuses", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); @@ -16,5 +18,16 @@ public class GetAccountStatuses extends MastodonAPIRequest>{ addQueryParameter("min_id", minID); if(limit>0) addQueryParameter("limit", ""+limit); + switch(filter){ + case DEFAULT -> addQueryParameter("exclude_replies", "true"); + case INCLUDE_REPLIES -> {} + case MEDIA -> addQueryParameter("only_media", "true"); + } + } + + public enum Filter{ + DEFAULT, + INCLUDE_REPLIES, + MEDIA } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 2685dbe06..3c6ae3f51 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -189,6 +189,10 @@ public class AccountSessionManager{ .execNoAuth(instance.uri); } + public boolean isSelf(String id, Account other){ + return getAccount(id).self.id.equals(other.id); + } + public Instance getAuthenticatingInstance(){ return authenticatingInstance; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java new file mode 100644 index 000000000..a04eb7699 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -0,0 +1,63 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Status; +import org.parceler.Parcels; + +import java.util.List; + +import me.grishka.appkit.api.SimpleCallback; + +public class AccountTimelineFragment extends StatusListFragment{ + private Account user; + private GetAccountStatuses.Filter filter; + + public AccountTimelineFragment(){ + setListLayoutId(R.layout.recycler_fragment_no_refresh); + } + + public static AccountTimelineFragment newInstance(String accountID, Account profileAccount, GetAccountStatuses.Filter filter, boolean load){ + AccountTimelineFragment f=new AccountTimelineFragment(); + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(profileAccount)); + args.putString("filter", filter.toString()); + if(!load) + args.putBoolean("noAutoLoad", true); + args.putBoolean("__is_tab", true); + f.setArguments(args); + return f; + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + user=Parcels.unwrap(getArguments().getParcelable("profileAccount")); + filter=GetAccountStatuses.Filter.valueOf(getArguments().getString("filter")); + if(!getArguments().getBoolean("noAutoLoad")) + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + onDataLoaded(result, !result.isEmpty()); + } + }) + .exec(accountID); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index 0953972de..454475020 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -21,6 +21,7 @@ import org.parceler.Parcels; import androidx.annotation.IdRes; import androidx.annotation.Nullable; +import me.grishka.appkit.FragmentStackActivity; import me.grishka.appkit.fragments.AppKitFragment; import me.grishka.appkit.fragments.LoaderFragment; import me.grishka.appkit.imageloader.ViewImageLoader; @@ -106,7 +107,7 @@ public class HomeFragment extends AppKitFragment{ @Override public boolean wantsLightStatusBar(){ - return true; + return currentTab!=R.id.tab_profile; } @Override @@ -119,10 +120,15 @@ public class HomeFragment extends AppKitFragment{ if(Build.VERSION.SDK_INT>=27){ int inset=insets.getSystemWindowInsetBottom(); tabBarWrap.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); - super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0)); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), 0)); }else{ - super.onApplyWindowInsets(insets); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); } + WindowInsets topOnlyInsets=insets.replaceSystemWindowInsets(0, insets.getSystemWindowInsetTop(), 0, 0); + homeTimelineFragment.onApplyWindowInsets(topOnlyInsets); + searchFragment.onApplyWindowInsets(topOnlyInsets); + notificationsFragment.onApplyWindowInsets(topOnlyInsets); + profileFragment.onApplyWindowInsets(topOnlyInsets); } private Fragment fragmentForTab(@IdRes int tab){ @@ -147,5 +153,6 @@ public class HomeFragment extends AppKitFragment{ lf.loadData(); } currentTab=tab; + ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index f2bb6fef6..a50038d8c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -1,37 +1,387 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.app.Fragment; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Matrix; +import android.graphics.Outline; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toolbar; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.ui.drawables.CoverOverlayGradientDrawable; +import org.joinmastodon.android.ui.tabs.TabLayout; +import org.joinmastodon.android.ui.tabs.TabLayoutMediator; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.CoverImageView; +import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.parceler.Parcels; +import java.util.Collections; import java.util.List; -import me.grishka.appkit.api.SimpleCallback; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.viewpager2.widget.ViewPager2; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.BaseRecyclerFragment; +import me.grishka.appkit.fragments.LoaderFragment; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; -public class ProfileFragment extends StatusListFragment{ - private Account user; +public class ProfileFragment extends LoaderFragment{ + + private ImageView avatar; + private CoverImageView cover; + private View avatarBorder; + private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel, postsCount, postsLabel; + private Button actionButton; + private ViewPager2 pager; + private NestedRecyclerScrollView scrollView; + private AccountTimelineFragment postsFragment, postsWithRepliesFragment, mediaFragment; + private TabLayout tabbar; + private SwipeRefreshLayout refreshLayout; + private CoverOverlayGradientDrawable coverGradient=new CoverOverlayGradientDrawable(); + private Matrix coverMatrix=new Matrix(); + private float titleTransY; + + private Account account; + private String accountID; + private Relationship relationship; + private int statusBarHeight; + + public ProfileFragment(){ + super(R.layout.loader_fragment_overlay_toolbar); + } @Override public void onAttach(Activity activity){ super.onAttach(activity); - user=Parcels.unwrap(getArguments().getParcelable("profileAccount")); - setTitle("@"+user.acct); - if(!getArguments().getBoolean("noAutoLoad")) + accountID=getArguments().getString("account"); + setHasOptionsMenu(true); + if(!getArguments().getBoolean("noAutoLoad", false)) loadData(); } @Override - protected void doLoadData(int offset, int count){ - currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count) - .setCallback(new SimpleCallback<>(this){ + public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + View content=inflater.inflate(R.layout.fragment_profile, container, false); + + avatar=content.findViewById(R.id.avatar); + cover=content.findViewById(R.id.cover); + avatarBorder=content.findViewById(R.id.avatar_border); + name=content.findViewById(R.id.name); + username=content.findViewById(R.id.username); + bio=content.findViewById(R.id.bio); + followersCount=content.findViewById(R.id.followers_count); + followersLabel=content.findViewById(R.id.followers_label); + followingCount=content.findViewById(R.id.following_count); + followingLabel=content.findViewById(R.id.following_label); + postsCount=content.findViewById(R.id.posts_count); + postsLabel=content.findViewById(R.id.posts_label); + actionButton=content.findViewById(R.id.profile_action_btn); + pager=content.findViewById(R.id.pager); + scrollView=content.findViewById(R.id.scroller); + tabbar=content.findViewById(R.id.tabbar); + refreshLayout=content.findViewById(R.id.refresh_layout); + + avatar.setOutlineProvider(new ViewOutlineProvider(){ + @Override + public void getOutline(View view, Outline outline){ + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), V.dp(25)); + } + }); + avatar.setClipToOutline(true); + + pager.setOffscreenPageLimit(4); + pager.setAdapter(new ProfilePagerAdapter()); + pager.getLayoutParams().height=getResources().getDisplayMetrics().heightPixels; + + if(getArguments().containsKey("profileAccount")){ + account=Parcels.unwrap(getArguments().getParcelable("profileAccount")); + bindHeaderView(); + dataLoaded(); + loadRelationship(); + } + + scrollView.setScrollableChildSupplier(this::getScrollableRecyclerView); + + FrameLayout sizeWrapper=new FrameLayout(getActivity()){ + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ + Toolbar toolbar=getToolbar(); + pager.getLayoutParams().height=MeasureSpec.getSize(heightMeasureSpec)-getPaddingTop()-getPaddingBottom()-toolbar.getLayoutParams().height-statusBarHeight-V.dp(38); + coverGradient.setTopPadding(statusBarHeight+toolbar.getLayoutParams().height); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + }; + sizeWrapper.addView(content); + + tabbar.setTabTextColors(getResources().getColor(R.color.gray_500), getResources().getColor(R.color.gray_800)); + tabbar.setTabTextSize(V.dp(16)); + new TabLayoutMediator(tabbar, pager, new TabLayoutMediator.TabConfigurationStrategy(){ + @Override + public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){ + tab.setText(switch(position){ + case 0 -> R.string.posts; + case 1 -> R.string.posts_and_replies; + case 2 -> R.string.media; + case 3 -> R.string.profile_about; + default -> throw new IllegalStateException(); + }); + } + }).attach(); + + cover.setForeground(coverGradient); + + return sizeWrapper; + } + + @Override + protected void doLoadData(){ + } + + @Override + public void onRefresh(){ + + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + updateToolbar(); + // To avoid the callback triggering on first layout with position=0 before anything is instantiated + pager.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + pager.getViewTreeObserver().removeOnPreDrawListener(this); + pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ @Override - public void onSuccess(List result){ - onDataLoaded(result, !result.isEmpty()); + public void onPageSelected(int position){ + if(position==0) + return; + BaseRecyclerFragment page=getFragmentForPage(position); + if(!page.loaded && !page.isDataLoading()) + page.loadData(); + } + }); + return true; + } + }); + + scrollView.setOnScrollChangeListener(this::onScrollChanged); + titleTransY=getToolbar().getLayoutParams().height; + if(toolbarTitleView!=null){ + toolbarTitleView.setTranslationY(titleTransY); + toolbarSubtitleView.setTranslationY(titleTransY); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig){ + super.onConfigurationChanged(newConfig); + updateToolbar(); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + statusBarHeight=insets.getSystemWindowInsetTop(); + ((ViewGroup.MarginLayoutParams)getToolbar().getLayoutParams()).topMargin=statusBarHeight; + refreshLayout.setProgressViewEndTarget(true, statusBarHeight+refreshLayout.getProgressCircleDiameter()+V.dp(24)); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + } + + private void bindHeaderView(){ + setTitle(account.displayName); + setSubtitle(getResources().getQuantityString(R.plurals.x_posts, account.statusesCount, account.statusesCount)); + ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(account.avatar, V.dp(100), V.dp(100))); + ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(account.header, 1000, 1000)); + name.setText(account.displayName); + username.setText('@'+account.acct); + bio.setText(HtmlParser.parse(account.note, account.emojis)); + followersCount.setText(UiUtils.abbreviateNumber(account.followersCount)); + followingCount.setText(UiUtils.abbreviateNumber(account.followingCount)); + postsCount.setText(UiUtils.abbreviateNumber(account.statusesCount)); + followersLabel.setText(getResources().getQuantityString(R.plurals.followers, account.followersCount)); + followingLabel.setText(getResources().getQuantityString(R.plurals.following, account.followingCount)); + postsLabel.setText(getResources().getQuantityString(R.plurals.posts, account.statusesCount)); + + if(AccountSessionManager.getInstance().isSelf(accountID, account)){ + actionButton.setText(R.string.edit_profile); + }else{ + actionButton.setVisibility(View.GONE); + } + } + + private void updateToolbar(){ + getToolbar().setBackgroundColor(0); + if(toolbarTitleView!=null){ + toolbarTitleView.setTranslationY(titleTransY); + toolbarSubtitleView.setTranslationY(titleTransY); + } + } + + @Override + public boolean wantsLightStatusBar(){ + return false; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + if(relationship==null) + return; + inflater.inflate(R.menu.profile, menu); + menu.findItem(R.id.mention).setTitle(getString(R.string.mention_user, account.displayName)); + menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.displayName)); + menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.displayName)); + menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.displayName)); + menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.displayName)); + String domain=account.getDomain(); + if(domain!=null) + menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, domain)); + else + menu.findItem(R.id.block_domain).setVisible(false); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + int id=item.getItemId(); + if(id==R.id.share){ + Intent intent=new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TEXT, account.url); + startActivity(Intent.createChooser(intent, item.getTitle())); + } + return true; + } + + @Override + protected int getToolbarResource(){ + return R.layout.profile_toolbar; + } + + private void loadRelationship(){ + new GetAccountRelationships(Collections.singletonList(account.id)) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + relationship=result.get(0); + invalidateOptionsMenu(); + actionButton.setVisibility(View.VISIBLE); + actionButton.setText(relationship.following ? R.string.button_following : R.string.button_follow); + } + + @Override + public void onError(ErrorResponse error){ + } }) .exec(accountID); } + + private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){ + int topBarsH=getToolbar().getHeight()+statusBarHeight; + if(scrollY>avatar.getTop()-topBarsH){ + float avaAlpha=Math.max(1f-((scrollY-(avatar.getTop()-topBarsH))/(float)V.dp(38)), 0f); + avatar.setAlpha(avaAlpha); + avatarBorder.setAlpha(avaAlpha); + }else{ + avatar.setAlpha(1f); + avatarBorder.setAlpha(1f); + } + if(scrollY>cover.getHeight()-topBarsH){ + cover.setTranslationY(scrollY-(cover.getHeight()-topBarsH)); + cover.setTranslationZ(V.dp(10)); + cover.setTransform(cover.getHeight()/2f-topBarsH/2f, 1f); + }else{ + cover.setTranslationY(0f); + cover.setTranslationZ(0f); + cover.setTransform(scrollY/2f, 1f); + } + coverGradient.setTopOffset(scrollY); + cover.invalidate(); + titleTransY=getToolbar().getHeight(); + if(scrollY>name.getTop()-topBarsH){ + titleTransY=Math.max(0f, titleTransY-(scrollY-(name.getTop()-topBarsH))); + } + if(toolbarTitleView!=null){ + toolbarTitleView.setTranslationY(titleTransY); + toolbarSubtitleView.setTranslationY(titleTransY); + } + } + + private BaseRecyclerFragment getFragmentForPage(int page){ + return switch(page){ + case 0 -> postsFragment; + case 1 -> postsWithRepliesFragment; + case 2 -> mediaFragment; + default -> throw new IllegalStateException(); + }; + } + + private RecyclerView getScrollableRecyclerView(){ + return getFragmentForPage(pager.getCurrentItem()).getView().findViewById(R.id.list); + } + + private class ProfilePagerAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + FrameLayout view=new FrameLayout(getActivity()); + view.setId(View.generateViewId()); + view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + return new SimpleViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){ + Fragment fragment=switch(position){ + case 0 -> postsFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.DEFAULT, true); + case 1 -> postsWithRepliesFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.INCLUDE_REPLIES, false); + case 2 -> mediaFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.MEDIA, false); + default -> throw new IllegalArgumentException(); + }; + getChildFragmentManager().beginTransaction().add(holder.itemView.getId(), fragment).commit(); + } + + @Override + public int getItemCount(){ + return 3; + } + + @Override + public int getItemViewType(int position){ + return position; + } + } + + private class SimpleViewHolder extends RecyclerView.ViewHolder{ + public SimpleViewHolder(@NonNull View itemView){ + super(itemView); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java index 4d64a053c..5c425b6d1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java @@ -1,5 +1,7 @@ package org.joinmastodon.android.model; +import android.text.TextUtils; + import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; import org.parceler.Parcel; @@ -147,6 +149,15 @@ public class Account extends BaseModel{ moved.postprocess(); } + public boolean isLocal(){ + return !acct.contains("@"); + } + + public String getDomain(){ + String[] parts=acct.split("@", 2); + return parts.length==1 ? null : parts[1]; + } + @Override public String toString(){ return "Account{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java b/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java new file mode 100644 index 000000000..837178fef --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java @@ -0,0 +1,39 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.RequiredField; + +public class Relationship extends BaseModel{ + @RequiredField + public String id; + public boolean following; + public boolean requested; + public boolean endorsed; + public boolean followedBy; + public boolean muting; + public boolean mutingNotifications; + public boolean showingReblogs; + public boolean notifying; + public boolean blocking; + public boolean domainBlocking; + public boolean blockedBy; + public String note; + + @Override + public String toString(){ + return "Relationship{"+ + "id='"+id+'\''+ + ", following="+following+ + ", requested="+requested+ + ", endorsed="+endorsed+ + ", followedBy="+followedBy+ + ", muting="+muting+ + ", mutingNotifications="+mutingNotifications+ + ", showingReblogs="+showingReblogs+ + ", notifying="+notifying+ + ", blocking="+blocking+ + ", domainBlocking="+domainBlocking+ + ", blockedBy="+blockedBy+ + ", note='"+note+'\''+ + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index 81bf35c6b..e5a6bfd5c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -5,7 +5,6 @@ import android.app.Fragment; import android.graphics.Outline; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; -import android.os.Build; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/CoverOverlayGradientDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/CoverOverlayGradientDrawable.java new file mode 100644 index 000000000..42a6e97c0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/CoverOverlayGradientDrawable.java @@ -0,0 +1,58 @@ +package org.joinmastodon.android.ui.drawables; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.LinearGradient; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import me.grishka.appkit.utils.V; + +public class CoverOverlayGradientDrawable extends Drawable{ + private LinearGradient gradient=new LinearGradient(0f, 0f, 0f, 100f, 0xB0000000, 0, Shader.TileMode.CLAMP); + private Matrix gradientMatrix=new Matrix(); + private int topPadding, topOffset; + private Paint paint=new Paint(); + + public CoverOverlayGradientDrawable(){ + paint.setShader(gradient); + } + + @Override + public void draw(@NonNull Canvas canvas){ + Rect bounds=getBounds(); + gradientMatrix.setScale(1f, (bounds.height()-V.dp(40)-topPadding)/100f); + gradientMatrix.postTranslate(0, topPadding+topOffset); + gradient.setLocalMatrix(gradientMatrix); + canvas.drawRect(bounds, paint); + } + + @Override + public void setAlpha(int alpha){ + + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter){ + + } + + @Override + public int getOpacity(){ + return PixelFormat.TRANSLUCENT; + } + + public void setTopPadding(int topPadding){ + this.topPadding=topPadding; + } + + public void setTopOffset(int topOffset){ + this.topOffset=topOffset; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/ElasticTabIndicatorInterpolator.java b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/ElasticTabIndicatorInterpolator.java new file mode 100644 index 000000000..68d5c5023 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/ElasticTabIndicatorInterpolator.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2020 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 org.joinmastodon.android.ui.tabs; + +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.view.View; +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import me.grishka.appkit.utils.V; + +import static org.joinmastodon.android.ui.utils.UiUtils.lerp; + +/** + * An implementation of {@link TabIndicatorInterpolator} that translates the left and right sides of + * a selected tab indicator independently to make the indicator grow and shrink between + * destinations. + */ +class ElasticTabIndicatorInterpolator extends TabIndicatorInterpolator { + + /** Fit a linear 0F - 1F curve to an ease out sine (decelerating) curve. */ + private static float decInterp(@FloatRange(from = 0.0, to = 1.0) float fraction) { + // Ease out sine + return (float) Math.sin((fraction * Math.PI) / 2.0); + } + + /** Fit a linear 0F - 1F curve to an ease in sine (accelerating) curve. */ + private static float accInterp(@FloatRange(from = 0.0, to = 1.0) float fraction) { + // Ease in sine + return (float) (1.0 - Math.cos((fraction * Math.PI) / 2.0)); + } + + @Override + void setIndicatorBoundsForOffset( + TabLayout tabLayout, + View startTitle, + View endTitle, + float offset, + @NonNull Drawable indicator) { + // The indicator should be positioned somewhere between start and end title. Override the + // super implementation and adjust the indicator's left and right bounds independently. + RectF startIndicator = calculateIndicatorWidthForTab(tabLayout, startTitle); + RectF endIndicator = calculateIndicatorWidthForTab(tabLayout, endTitle); + + float leftFraction; + float rightFraction; + + final boolean isMovingRight = startIndicator.left < endIndicator.left; + // If the selection indicator should grow and shrink during the animation, interpolate + // the left and right bounds of the indicator using separate easing functions. + // The side in which the indicator is moving should always be the accelerating + // side. + if (isMovingRight) { + leftFraction = accInterp(offset); + rightFraction = decInterp(offset); + } else { + leftFraction = decInterp(offset); + rightFraction = accInterp(offset); + } + indicator.setBounds( + lerp((int) startIndicator.left, (int) endIndicator.left, leftFraction), + indicator.getBounds().top, + lerp((int) startIndicator.right, (int) endIndicator.right, rightFraction), + indicator.getBounds().bottom); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/MaterialResources.java b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/MaterialResources.java new file mode 100644 index 000000000..5880e379d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/MaterialResources.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.ui.tabs; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; + +class MaterialResources{ + public static Drawable getDrawable(Context context, TypedArray a, int attr){ + return a.getDrawable(attr); + } + + public static ColorStateList getColorStateList(Context context, TypedArray a, int attr){ + return a.getColorStateList(attr); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabIndicatorInterpolator.java b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabIndicatorInterpolator.java new file mode 100644 index 000000000..964e18d98 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabIndicatorInterpolator.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2020 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 org.joinmastodon.android.ui.tabs; + +import static org.joinmastodon.android.ui.utils.UiUtils.lerp; + +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.view.View; +import androidx.annotation.Dimension; +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import me.grishka.appkit.utils.V; + +/** + * A class used to manipulate the {@link SlidingTabIndicator}'s indicator {@link Drawable} at any + * point at or between tabs. + * + *

By default, this class will size the indicator according to {@link + * TabLayout#isTabIndicatorFullWidth()} and linearly move the indicator between tabs. + * + *

Subclasses can override {@link #setIndicatorBoundsForTab(TabLayout, View, Drawable)} and + * {@link #setIndicatorBoundsForOffset(TabLayout, View, View, float, Drawable)} (TabLayout, View, + * View, float, Drawable)} to define how the indicator should be drawn for a single tab or at any + * point between two tabs. + * + *

Additionally, subclasses can use the provided helpers {@link + * #calculateIndicatorWidthForTab(TabLayout, View)} and {@link + * #calculateTabViewContentBounds(TabView, int)} to capture the bounds of the tab or tab's content. + */ +class TabIndicatorInterpolator { + + @Dimension(unit = Dimension.DP) + private static final int MIN_INDICATOR_WIDTH = 24; + + /** + * A helper method that calculates the bounds of a {@link TabView}'s content. + * + *

For width, if only text label is present, calculates the width of the text label. If only + * icon is present, calculates the width of the icon. If both are present, the text label bounds + * take precedence. If both are present and inline mode is enabled, the sum of the bounds of the + * both the text label and icon are calculated. If neither are present or if the calculated + * difference between the left and right bounds is less than 24dp, then left and right bounds are + * adjusted such that the difference between them is equal to 24dp. + * + *

For height, this method calculates the combined height of the icon (if present) and label + * (if present). + * + * @param tabView {@link TabView} for which to calculate left and right content bounds. + * @param minWidth the min width between the returned RectF's left and right bounds. Useful if + * enforcing a min width of the indicator. + */ + static RectF calculateTabViewContentBounds( + @NonNull TabLayout.TabView tabView, @Dimension(unit = Dimension.DP) int minWidth) { + int tabViewContentWidth = tabView.getContentWidth(); + int tabViewContentHeight = tabView.getContentHeight(); + int minWidthPx = (int) V.dp(minWidth); + + if (tabViewContentWidth < minWidthPx) { + tabViewContentWidth = minWidthPx; + } + + int tabViewCenterX = (tabView.getLeft() + tabView.getRight()) / 2; + int tabViewCenterY = (tabView.getTop() + tabView.getBottom()) / 2; + int contentLeftBounds = tabViewCenterX - (tabViewContentWidth / 2); + int contentTopBounds = tabViewCenterY - (tabViewContentHeight / 2); + int contentRightBounds = tabViewCenterX + (tabViewContentWidth / 2); + int contentBottomBounds = tabViewCenterY + (tabViewCenterX / 2); + + return new RectF(contentLeftBounds, contentTopBounds, contentRightBounds, contentBottomBounds); + } + + /** + * A helper method to calculate the left and right bounds of an indicator when {@code tab} is + * selected. + * + *

This method accounts for {@link TabLayout#isTabIndicatorFullWidth()}'s value. If true, the + * returned left and right bounds will span the full width of {@code tab}. If false, the returned + * bounds will span the width of the {@code tab}'s content. + * + * @param tabLayout The tab's parent {@link TabLayout} + * @param tab The view of the tab under which the indicator will be positioned + * @return A {@link RectF} containing the left and right bounds that the indicator should span + * when {@code tab} is selected. + */ + static RectF calculateIndicatorWidthForTab(TabLayout tabLayout, @Nullable View tab) { + if (tab == null) { + return new RectF(); + } + + // If the indicator should fit to the tab's content, calculate the content's widtd + if (!tabLayout.isTabIndicatorFullWidth() && tab instanceof TabLayout.TabView) { + return calculateTabViewContentBounds((TabLayout.TabView) tab, MIN_INDICATOR_WIDTH); + } + + // Return the entire width of the tab + return new RectF(tab.getLeft(), tab.getTop(), tab.getRight(), tab.getBottom()); + } + + /** + * Called whenever {@code indicator} should be drawn to show the given {@code tab} as selected. + * + *

This method should update the bounds of indicator to be correctly positioned to indicate + * {@code tab} as selected. + * + * @param tabLayout The {@link TabLayout} parent of the tab and indicator being drawn. + * @param tab The tab that should be marked as selected + * @param indicator The drawable to be drawn to indicate the selected tab. Update the drawable's + * bounds, color, etc to mark the given tab as selected. + */ + void setIndicatorBoundsForTab(TabLayout tabLayout, View tab, @NonNull Drawable indicator) { + RectF startIndicator = calculateIndicatorWidthForTab(tabLayout, tab); + indicator.setBounds( + (int) startIndicator.left, + indicator.getBounds().top, + (int) startIndicator.right, + indicator.getBounds().bottom); + } + + /** + * Called whenever the {@code indicator} should be drawn between two destinations and the {@link + * Drawable}'s bounds should be changed. When {@code offset} is 0.0, the tab {@code indicator} + * should indicate that the {@code startTitle} tab is selected. When {@code offset} is 1.0, the + * tab {@code indicator} should indicate that the {@code endTitle} tab is selected. When offset is + * between 0.0 and 1.0, the {@code indicator} is moving between the startTitle and endTitle and + * the indicator should reflect this movement. + * + *

By default, this class will move the indicator linearly between tab destinations. + * + * @param tabLayout The TabLayout parent of the indicator being drawn. + * @param startTitle The title that should be indicated as selected when offset is 0.0. + * @param endTitle The title that should be indicated as selected when offset is 1.0. + * @param offset The fraction between startTitle and endTitle where the indicator is for a given + * frame + * @param indicator The drawable to be drawn to indicate the selected tab. Update the drawable's + * bounds, color, etc as {@code offset} changes to show the indicator in the correct position. + */ + void setIndicatorBoundsForOffset( + TabLayout tabLayout, + View startTitle, + View endTitle, + @FloatRange(from = 0.0, to = 1.0) float offset, + @NonNull Drawable indicator) { + RectF startIndicator = calculateIndicatorWidthForTab(tabLayout, startTitle); + // Linearly interpolate the indicator's position, using it's left and right bounds, between the + // two destinations. + RectF endIndicator = calculateIndicatorWidthForTab(tabLayout, endTitle); + indicator.setBounds( + lerp((int) startIndicator.left, (int) endIndicator.left, offset), + indicator.getBounds().top, + lerp((int) startIndicator.right, (int) endIndicator.right, offset), + indicator.getBounds().bottom); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabItem.java new file mode 100644 index 000000000..ff2dc84b2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabItem.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016 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 org.joinmastodon.android.ui.tabs; + + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; + +import org.joinmastodon.android.R; + +/** + * TabItem is a special 'view' which allows you to declare tab items for a {@link TabLayout} within + * a layout. This view is not actually added to TabLayout, it is just a dummy which allows setting + * of a tab items's text, icon and custom layout. See TabLayout for more information on how to use + * it. + * + * @attr ref com.google.android.material.R.styleable#TabItem_android_icon + * @attr ref com.google.android.material.R.styleable#TabItem_android_text + * @attr ref com.google.android.material.R.styleable#TabItem_android_layout + * @see TabLayout + */ +//TODO(b/76413401): make class final after the widget migration +public class TabItem extends View { + //TODO(b/76413401): make package private after the widget migration + public final CharSequence text; + //TODO(b/76413401): make package private after the widget migration + public final Drawable icon; + //TODO(b/76413401): make package private after the widget migration + public final int customLayout; + + public TabItem(Context context) { + this(context, null); + } + + public TabItem(Context context, AttributeSet attrs) { + super(context, attrs); + + final TypedArray a = + context.obtainStyledAttributes(attrs, R.styleable.TabItem); + text = a.getText(R.styleable.TabItem_android_text); + icon = a.getDrawable(R.styleable.TabItem_android_icon); + customLayout = a.getResourceId(R.styleable.TabItem_android_layout, 0); + a.recycle(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayout.java new file mode 100644 index 000000000..72a8bf70c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayout.java @@ -0,0 +1,3435 @@ +/* + * 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 org.joinmastodon.android.ui.tabs; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_DRAGGING; +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_IDLE; +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_SETTLING; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.database.DataSetObserver; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.drawable.RippleDrawable; +import android.os.Build; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.text.Layout; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.PointerIcon; +import android.view.SoundEffectConstants; +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.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; + +import androidx.annotation.BoolRes; +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.Dimension; +import androidx.annotation.DrawableRes; +import androidx.annotation.IntDef; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; +import androidx.annotation.StringRes; +import androidx.recyclerview.widget.util.Pools; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Iterator; + +/** + * TabLayout 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: + * + *

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

You can also add items to TabLayout in your layout through the use of {@link TabItem}. An + * example usage is like so: + * + *

+ * <com.google.android.material.tabs.TabLayout
+ *         android:layout_height="wrap_content"
+ *         android:layout_width="match_parent">
+ *
+ *     <com.google.android.material.tabs.TabItem
+ *             android:text="@string/tab_text"/>
+ *
+ *     <com.google.android.material.tabs.TabItem
+ *             android:icon="@drawable/ic_android"/>
+ *
+ * </com.google.android.material.tabs.TabLayout>
+ * 
+ * + *

ViewPager integration

+ * + *

If you're using a {@link androidx.viewpager.widget.ViewPager} together with this layout, you + * can call {@link #setupWithViewPager(ViewPager)} to link the two together. This layout will be + * automatically populated from the {@link PagerAdapter}'s page titles. + * + *

This view also supports being used as part of a ViewPager's decor, and can be added directly + * to the ViewPager in a layout resource file like so: + * + *

+ * <androidx.viewpager.widget.ViewPager
+ *     android:layout_width="match_parent"
+ *     android:layout_height="match_parent">
+ *
+ *     <com.google.android.material.tabs.TabLayout
+ *         android:layout_width="match_parent"
+ *         android:layout_height="wrap_content"
+ *         android:layout_gravity="top" />
+ *
+ * </androidx.viewpager.widget.ViewPager>
+ * 
+ * + * @see Tabs + * @attr ref com.google.android.material.R.styleable#TabLayout_tabPadding + * @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingStart + * @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingTop + * @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingEnd + * @attr ref com.google.android.material.R.styleable#TabLayout_tabPaddingBottom + * @attr ref com.google.android.material.R.styleable#TabLayout_tabContentStart + * @attr ref com.google.android.material.R.styleable#TabLayout_tabBackground + * @attr ref com.google.android.material.R.styleable#TabLayout_tabMinWidth + * @attr ref com.google.android.material.R.styleable#TabLayout_tabMaxWidth + * @attr ref com.google.android.material.R.styleable#TabLayout_tabTextAppearance + */ +@ViewPager.DecorView +public class TabLayout extends HorizontalScrollView { + + private static final CubicBezierInterpolator FAST_OUT_SLOW_IN_INTERPOLATOR=new CubicBezierInterpolator(.4f, 0f, .2f, 1f); + private static final int DEF_STYLE_RES = R.style.Widget_Design_TabLayout; + + @Dimension(unit = Dimension.DP) + private static final int DEFAULT_HEIGHT_WITH_TEXT_ICON = 72; + + @Dimension(unit = Dimension.DP) + static final int DEFAULT_GAP_TEXT_ICON = 8; + + @Dimension(unit = Dimension.DP) + private static final int DEFAULT_HEIGHT = 48; + + @Dimension(unit = Dimension.DP) + private static final int TAB_MIN_WIDTH_MARGIN = 56; + + @Dimension(unit = Dimension.DP) + static final int FIXED_WRAP_GUTTER_MIN = 16; + + private static final int INVALID_WIDTH = -1; + + private static final int ANIMATION_DURATION = 300; + + private static final Pools.Pool tabPool = new Pools.SynchronizedPool<>(16); + + private static final String LOG_TAG = "TabLayout"; + + /** + * 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; + + /** + * Auto-sizing tabs behave like MODE_FIXED with GRAVITY_CENTER while the tabs fit within the + * TabLayout's content width. Fixed tabs have equal width, based on the widest tab label. Once the + * tabs outgrow the view's width, auto-sizing tabs behave like MODE_SCROLLABLE, allowing for a + * dynamic number of tabs without requiring additional layout logic. + * + * @see #setTabMode(int) + * @see #getTabMode() + */ + public static final int MODE_AUTO = 2; + + /** @hide */ + @RestrictTo(LIBRARY_GROUP) + @IntDef(value = {MODE_SCROLLABLE, MODE_FIXED, MODE_AUTO}) + @Retention(RetentionPolicy.SOURCE) + public @interface Mode {} + + /** + * If a tab is instantiated with {@link Tab#setText(CharSequence)}, and this mode is set, the text + * will be saved and utilized for the content description, but no visible labels will be created. + * + * @see Tab#setTabLabelVisibility(int) + */ + public static final int TAB_LABEL_VISIBILITY_UNLABELED = 0; + + /** + * This mode is set by default. If a tab is instantiated with {@link Tab#setText(CharSequence)}, a + * visible label will be created. + * + * @see Tab#setTabLabelVisibility(int) + */ + public static final int TAB_LABEL_VISIBILITY_LABELED = 1; + + /** @hide */ + @IntDef(value = {TAB_LABEL_VISIBILITY_UNLABELED, TAB_LABEL_VISIBILITY_LABELED}) + public @interface LabelVisibility {} + + /** + * Gravity used to fill the {@link TabLayout} as much as possible. This option only takes effect + * when used with {@link #MODE_FIXED} on non-landscape screens less than 600dp wide. + * + * @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 TabLayout}. + * + * @see #setTabGravity(int) + * @see #getTabGravity() + */ + public static final int GRAVITY_CENTER = 1; + + /** + * Gravity used to lay out the tabs aligned to the start of the {@link TabLayout}. + * + * @see #setTabGravity(int) + * @see #getTabGravity() + */ + public static final int GRAVITY_START = 1 << 1; + + /** @hide */ + @RestrictTo(LIBRARY_GROUP) + @IntDef( + flag = true, + value = {GRAVITY_FILL, GRAVITY_CENTER, GRAVITY_START}) + @Retention(RetentionPolicy.SOURCE) + public @interface TabGravity {} + + /** + * Indicator gravity used to align the tab selection indicator to the bottom of the {@link + * TabLayout}. This will only take effect if the indicator height is set via the custom indicator + * drawable's intrinsic height (preferred), via the {@code tabIndicatorHeight} attribute + * (deprecated), or via {@link #setSelectedTabIndicatorHeight(int)} (deprecated). Otherwise, the + * indicator will not be shown. This is the default value. + * + * @see #setSelectedTabIndicatorGravity(int) + * @see #getTabIndicatorGravity() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorGravity + */ + public static final int INDICATOR_GRAVITY_BOTTOM = 0; + + /** + * Indicator gravity used to align the tab selection indicator to the center of the {@link + * TabLayout}. This will only take effect if the indicator height is set via the custom indicator + * drawable's intrinsic height (preferred), via the {@code tabIndicatorHeight} attribute + * (deprecated), or via {@link #setSelectedTabIndicatorHeight(int)} (deprecated). Otherwise, the + * indicator will not be shown. + * + * @see #setSelectedTabIndicatorGravity(int) + * @see #getTabIndicatorGravity() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorGravity + */ + public static final int INDICATOR_GRAVITY_CENTER = 1; + + /** + * Indicator gravity used to align the tab selection indicator to the top of the {@link + * TabLayout}. This will only take effect if the indicator height is set via the custom indicator + * drawable's intrinsic height (preferred), via the {@code tabIndicatorHeight} attribute + * (deprecated), or via {@link #setSelectedTabIndicatorHeight(int)} (deprecated). Otherwise, the + * indicator will not be shown. + * + * @see #setSelectedTabIndicatorGravity(int) + * @see #getTabIndicatorGravity() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorGravity + */ + public static final int INDICATOR_GRAVITY_TOP = 2; + + /** + * Indicator gravity used to stretch the tab selection indicator across the entire height and + * width of the {@link TabLayout}. This will disregard {@code tabIndicatorHeight} and the + * indicator drawable's intrinsic height, if set. + * + * @see #setSelectedTabIndicatorGravity(int) + * @see #getTabIndicatorGravity() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorGravity + */ + public static final int INDICATOR_GRAVITY_STRETCH = 3; + + /** @hide */ + @RestrictTo(LIBRARY_GROUP) + @IntDef( + value = { + INDICATOR_GRAVITY_BOTTOM, + INDICATOR_GRAVITY_CENTER, + INDICATOR_GRAVITY_TOP, + INDICATOR_GRAVITY_STRETCH + }) + @Retention(RetentionPolicy.SOURCE) + public @interface TabIndicatorGravity {} + + /** + * Indicator animation mode used to translate the selected tab indicator between two tabs using a + * linear motion. + * + *

The left and right side of the selection indicator translate in step over the duration of + * the animation. The only exception to this is when the indicator needs to change size to fit the + * width of its new destination tab's label. + * + * @see #setTabIndicatorAnimationMode(int) + * @see #getTabIndicatorAnimationMode() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode + */ + public static final int INDICATOR_ANIMATION_MODE_LINEAR = 0; + + /** + * Indicator animation mode used to translate the selected tab indicator by growing and then + * shrinking the indicator, making the indicator look like it is stretching while translating + * between destinations. + * + *

The left and right side of the selection indicator translate out of step - with the right + * decelerating and the left accelerating (when moving right). This difference in velocity between + * the sides of the indicator, over the duration of the animation, make the indicator look like it + * grows and then shrinks back down to fit it's new destination's width. + * + * @see #setTabIndicatorAnimationMode(int) + * @see #getTabIndicatorAnimationMode() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode + */ + public static final int INDICATOR_ANIMATION_MODE_ELASTIC = 1; + + /** @hide */ + @RestrictTo(LIBRARY_GROUP) + @IntDef(value = {INDICATOR_ANIMATION_MODE_LINEAR, INDICATOR_ANIMATION_MODE_ELASTIC}) + @Retention(RetentionPolicy.SOURCE) + public @interface TabIndicatorAnimationMode {} + + /** Callback interface invoked when a tab's selection state changes. */ + public interface OnTabSelectedListener extends BaseOnTabSelectedListener {} + + /** + * Callback interface invoked when a tab's selection state changes. + * + * @deprecated Use {@link OnTabSelectedListener} instead. + */ + @Deprecated + public interface BaseOnTabSelectedListener { + /** + * Called when a tab enters the selected state. + * + * @param tab The tab that was selected + */ + public void onTabSelected(T tab); + + /** + * Called when a tab exits the selected state. + * + * @param tab The tab that was unselected + */ + public void onTabUnselected(T 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(T tab); + } + + private final ArrayList tabs = new ArrayList<>(); + @Nullable private Tab selectedTab; + + @NonNull final SlidingTabIndicator slidingTabIndicator; + + int tabPaddingStart; + int tabPaddingTop; + int tabPaddingEnd; + int tabPaddingBottom; + + int tabTextAppearance; + ColorStateList tabTextColors; + ColorStateList tabIconTint; + ColorStateList tabRippleColorStateList; + @NonNull Drawable tabSelectedIndicator = new GradientDrawable(); + private int tabSelectedIndicatorColor = Color.TRANSPARENT; + + PorterDuff.Mode tabIconTintMode; + float tabTextSize; + float tabTextMultiLineSize; + + final int tabBackgroundResId; + + int tabMaxWidth = Integer.MAX_VALUE; + private final int requestedTabMinWidth; + private final int requestedTabMaxWidth; + private final int scrollableTabMinWidth; + + private int contentInsetStart; + + @TabGravity int tabGravity; + int tabIndicatorAnimationDuration; + @TabIndicatorGravity int tabIndicatorGravity; + @Mode int mode; + boolean inlineLabel; + boolean tabIndicatorFullWidth; + int tabIndicatorHeight = -1; + @TabIndicatorAnimationMode int tabIndicatorAnimationMode; + boolean unboundedRipple; + + private TabIndicatorInterpolator tabIndicatorInterpolator; + + @Nullable private BaseOnTabSelectedListener selectedListener; + + private final ArrayList selectedListeners = new ArrayList<>(); + @Nullable private BaseOnTabSelectedListener currentVpSelectedListener; + + private ValueAnimator scrollAnimator; + + @Nullable ViewPager viewPager; + @Nullable private PagerAdapter pagerAdapter; + private DataSetObserver pagerAdapterObserver; + private TabLayoutOnPageChangeListener pageChangeListener; + private AdapterChangeListener adapterChangeListener; + private boolean setupViewPagerImplicitly; + + // Pool we use as a simple RecyclerBin + private final Pools.Pool tabViewPool = new Pools.SimplePool<>(12); + + public TabLayout(@NonNull Context context) { + this(context, null); + } + + public TabLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.tabStyle); + } + + public TabLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + // Ensure we are using the correctly themed context rather than the context that was passed in. +// context = getContext(); + + // Disable the Scroll Bar + setHorizontalScrollBarEnabled(false); + + // Add the TabStrip + slidingTabIndicator = new SlidingTabIndicator(context); + super.addView( + slidingTabIndicator, + 0, + new LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); + + TypedArray a = + context.obtainStyledAttributes( + attrs, + R.styleable.TabLayout, + defStyleAttr, + DEF_STYLE_RES/*, + R.styleable.TabLayout_tabTextAppearance*/); + +// if (getBackground() instanceof ColorDrawable) { +// ColorDrawable background = (ColorDrawable) getBackground(); +// MaterialShapeDrawable materialShapeDrawable = new MaterialShapeDrawable(); +// materialShapeDrawable.setFillColor(ColorStateList.valueOf(background.getColor())); +// materialShapeDrawable.initializeElevationOverlay(context); +// materialShapeDrawable.setElevation(ViewCompat.getElevation(this)); +// ViewCompat.setBackground(this, materialShapeDrawable); +// } + + setSelectedTabIndicator( + MaterialResources.getDrawable(context, a, R.styleable.TabLayout_tabIndicator)); + setSelectedTabIndicatorColor( + a.getColor(R.styleable.TabLayout_tabIndicatorColor, Color.TRANSPARENT)); + slidingTabIndicator.setSelectedIndicatorHeight( + a.getDimensionPixelSize(R.styleable.TabLayout_tabIndicatorHeight, -1)); + setSelectedTabIndicatorGravity( + a.getInt(R.styleable.TabLayout_tabIndicatorGravity, INDICATOR_GRAVITY_BOTTOM)); + setTabIndicatorAnimationMode( + a.getInt(R.styleable.TabLayout_tabIndicatorAnimationMode, INDICATOR_ANIMATION_MODE_LINEAR)); + setTabIndicatorFullWidth(a.getBoolean(R.styleable.TabLayout_tabIndicatorFullWidth, true)); + + tabPaddingStart = + tabPaddingTop = + tabPaddingEnd = + tabPaddingBottom = a.getDimensionPixelSize(R.styleable.TabLayout_tabPadding, 0); + tabPaddingStart = + a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingStart, tabPaddingStart); + tabPaddingTop = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingTop, tabPaddingTop); + tabPaddingEnd = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingEnd, tabPaddingEnd); + tabPaddingBottom = + a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingBottom, tabPaddingBottom); + + tabTextAppearance = + a.getResourceId(R.styleable.TabLayout_tabTextAppearance, R.style.TextAppearance_Design_Tab); + + // Text colors/sizes come from the text appearance first +// final TypedArray ta = +// context.obtainStyledAttributes( +// tabTextAppearance, androidx.appcompat.R.styleable.TextAppearance); +// try { +// tabTextSize = +// ta.getDimensionPixelSize( +// android.R.attr.textSize, 0); +// tabTextColors = +// MaterialResources.getColorStateList( +// context, +// ta, +// androidx.appcompat.R.styleable.TextAppearance_android_textColor); +// } finally { +// ta.recycle(); +// } + + if (a.hasValue(R.styleable.TabLayout_tabTextColor)) { + // If we have an explicit text color set, use it instead + tabTextColors = + MaterialResources.getColorStateList(context, a, 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); + tabTextColors = createColorStateList(tabTextColors.getDefaultColor(), selected); + } + + tabIconTint = + MaterialResources.getColorStateList(context, a, R.styleable.TabLayout_tabIconTint); + tabIconTintMode = PorterDuff.Mode.SRC_OVER; +// ViewUtils.parseTintMode(a.getInt(R.styleable.TabLayout_tabIconTintMode, -1), null); + + tabRippleColorStateList = + MaterialResources.getColorStateList(context, a, R.styleable.TabLayout_tabRippleColor); + + tabIndicatorAnimationDuration = + a.getInt(R.styleable.TabLayout_tabIndicatorAnimationDuration, ANIMATION_DURATION); + + requestedTabMinWidth = + a.getDimensionPixelSize(R.styleable.TabLayout_tabMinWidth, INVALID_WIDTH); + requestedTabMaxWidth = + a.getDimensionPixelSize(R.styleable.TabLayout_tabMaxWidth, INVALID_WIDTH); + tabBackgroundResId = a.getResourceId(R.styleable.TabLayout_tabBackground, 0); + contentInsetStart = a.getDimensionPixelSize(R.styleable.TabLayout_tabContentStart, 0); + // noinspection WrongConstant + mode = a.getInt(R.styleable.TabLayout_tabMode, MODE_FIXED); + tabGravity = a.getInt(R.styleable.TabLayout_tabGravity, GRAVITY_FILL); + inlineLabel = a.getBoolean(R.styleable.TabLayout_tabInlineLabel, false); + unboundedRipple = a.getBoolean(R.styleable.TabLayout_tabUnboundedRipple, false); + a.recycle(); + + // TODO add attr for these + final Resources res = getResources(); + tabTextMultiLineSize = res.getDimensionPixelSize(R.dimen.design_tab_text_size_2line); + scrollableTabMinWidth = res.getDimensionPixelSize(R.dimen.design_tab_scrollable_min_width); + + // Now apply the tab mode and gravity + applyModeAndGravity(); + } + + /** + * Sets the tab indicator's color for the currently selected tab. + * + *

If the tab indicator color is not {@code Color.TRANSPARENT}, the indicator will be wrapped + * and tinted right before it is drawn by {@link SlidingTabIndicator#draw(Canvas)}. If you'd like + * the inherent color or the tinted color of a custom drawable to be used, make sure this color is + * set to {@code Color.TRANSPARENT} to avoid your color/tint being overriden. + * + * @param color color to use for the indicator + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorColor + */ + public void setSelectedTabIndicatorColor(@ColorInt int color) { + this.tabSelectedIndicatorColor = color; + updateTabViews(false); + } + + /** + * Sets the tab indicator's height for the currently selected tab. + * + * @deprecated If possible, set the intrinsic height directly on a custom indicator drawable + * passed to {@link #setSelectedTabIndicator(Drawable)}. + * @param height height to use for the indicator in pixels + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorHeight + */ + @Deprecated + public void setSelectedTabIndicatorHeight(int height) { + tabIndicatorHeight = height; + slidingTabIndicator.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 androidx.viewpager.widget.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. + * @see #setScrollPosition(int, float, boolean, boolean) + */ + public void setScrollPosition(int position, float positionOffset, boolean updateSelectedText) { + setScrollPosition(position, positionOffset, updateSelectedText, true); + } + + /** + * 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 androidx.viewpager.widget.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. + * @param updateIndicatorPosition Whether to set the indicator to the given position and offset. + * @see #setScrollPosition(int, float, boolean) + */ + public void setScrollPosition( + int position, + float positionOffset, + boolean updateSelectedText, + boolean updateIndicatorPosition) { + final int roundedPosition = Math.round(position + positionOffset); + if (roundedPosition < 0 || roundedPosition >= slidingTabIndicator.getChildCount()) { + return; + } + + // Set the indicator position, if enabled + if (updateIndicatorPosition) { + slidingTabIndicator.setIndicatorPositionFromTabPosition(position, positionOffset); + } + + // Now update the scroll position, canceling any running animation + if (scrollAnimator != null && scrollAnimator.isRunning()) { + scrollAnimator.cancel(); + } + scrollTo(position < 0 ? 0 : calculateScrollXForTab(position, positionOffset), 0); + + // Update the 'selected state' view as we scroll, if enabled + if (updateSelectedText) { + setSelectedTabView(roundedPosition); + } + } + + /** + * 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, tabs.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, tabs.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) { + addTab(tab, tabs.size(), setSelected); + } + + /** + * 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.parent != this) { + throw new IllegalArgumentException("Tab belongs to a different TabLayout."); + } + configureTab(tab, position); + addTabView(tab); + + if (setSelected) { + tab.select(); + } + } + + private void addTabFromItemView(@NonNull TabItem item) { + final Tab tab = newTab(); + if (item.text != null) { + tab.setText(item.text); + } + if (item.icon != null) { + tab.setIcon(item.icon); + } + if (item.customLayout != 0) { + tab.setCustomView(item.customLayout); + } + if (!TextUtils.isEmpty(item.getContentDescription())) { + tab.setContentDescription(item.getContentDescription()); + } + addTab(tab); + } + + /** + * @deprecated Use {@link #addOnTabSelectedListener(OnTabSelectedListener)} and {@link + * #removeOnTabSelectedListener(OnTabSelectedListener)}. + */ + @Deprecated + public void setOnTabSelectedListener(@Nullable OnTabSelectedListener listener) { + setOnTabSelectedListener((BaseOnTabSelectedListener) listener); + } + + /** + * @deprecated Use {@link #addOnTabSelectedListener(OnTabSelectedListener)} and {@link + * #removeOnTabSelectedListener(OnTabSelectedListener)}. + */ + @Deprecated + public void setOnTabSelectedListener(@Nullable BaseOnTabSelectedListener listener) { + // The logic in this method emulates what we had before support for multiple + // registered listeners. + if (selectedListener != null) { + removeOnTabSelectedListener(selectedListener); + } + // Update the deprecated field so that we can remove the passed listener the next + // time we're called + selectedListener = listener; + if (listener != null) { + addOnTabSelectedListener(listener); + } + } + + /** + * Add a {@link OnTabSelectedListener} that will be invoked when tab selection changes. + * + *

Components that add a listener should take care to remove it when finished via {@link + * #removeOnTabSelectedListener(OnTabSelectedListener)}. + * + * @param listener listener to add + */ + public void addOnTabSelectedListener(@NonNull OnTabSelectedListener listener) { + addOnTabSelectedListener((BaseOnTabSelectedListener) listener); + } + + /** + * Add a {@link BaseOnTabSelectedListener} that will be invoked when tab selection + * changes. + * + *

Components that add a listener should take care to remove it when finished via {@link + * #removeOnTabSelectedListener(BaseOnTabSelectedListener)}. + * + * @param listener listener to add + * @deprecated use {@link #addOnTabSelectedListener(OnTabSelectedListener)} + */ + @Deprecated + public void addOnTabSelectedListener(@Nullable BaseOnTabSelectedListener listener) { + if (!selectedListeners.contains(listener)) { + selectedListeners.add(listener); + } + } + + /** + * Remove the given {@link OnTabSelectedListener} that was previously added via {@link + * #addOnTabSelectedListener(OnTabSelectedListener)}. + * + * @param listener listener to remove + */ + public void removeOnTabSelectedListener(@NonNull OnTabSelectedListener listener) { + removeOnTabSelectedListener((BaseOnTabSelectedListener) listener); + } + + /** + * Remove the given {@link BaseOnTabSelectedListener} that was previously added via + * {@link #addOnTabSelectedListener(BaseOnTabSelectedListener)}. + * + * @param listener listener to remove + * @deprecated use {@link #removeOnTabSelectedListener(OnTabSelectedListener)} + */ + @Deprecated + public void removeOnTabSelectedListener(@Nullable BaseOnTabSelectedListener listener) { + selectedListeners.remove(listener); + } + + /** Remove all previously added {@link OnTabSelectedListener}s. */ + public void clearOnTabSelectedListeners() { + selectedListeners.clear(); + } + + /** + * 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() { + Tab tab = createTabFromPool(); + tab.parent = this; + tab.view = createTabView(tab); + if (tab.id != NO_ID) { + tab.view.setId(tab.id); + } + + return tab; + } + + // TODO(b/76413401): remove this method and just create the final field after the widget migration + protected Tab createTabFromPool() { + Tab tab = tabPool.acquire(); + if (tab == null) { + tab = new Tab(); + } + return tab; + } + + // TODO(b/76413401): remove this method and just create the final field after the widget migration + protected boolean releaseFromTabPool(Tab tab) { + return tabPool.release(tab); + } + + /** + * Returns the number of tabs currently registered with the action bar. + * + * @return Tab count + */ + public int getTabCount() { + return tabs.size(); + } + + /** Returns the tab at the specified index. */ + @Nullable + public Tab getTabAt(int index) { + return (index < 0 || index >= getTabCount()) ? null : tabs.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 selectedTab != null ? selectedTab.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(@NonNull Tab tab) { + if (tab.parent != this) { + throw new IllegalArgumentException("Tab does not belong to this TabLayout."); + } + + 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 = selectedTab != null ? selectedTab.getPosition() : 0; + removeTabViewAt(position); + + final Tab removedTab = tabs.remove(position); + if (removedTab != null) { + removedTab.reset(); + releaseFromTabPool(removedTab); + } + + final int newTabCount = tabs.size(); + for (int i = position; i < newTabCount; i++) { + tabs.get(i).setPosition(i); + } + + if (selectedTabPosition == position) { + selectTab(tabs.isEmpty() ? null : tabs.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 + for (int i = slidingTabIndicator.getChildCount() - 1; i >= 0; i--) { + removeTabViewAt(i); + } + + for (final Iterator i = tabs.iterator(); i.hasNext(); ) { + final Tab tab = i.next(); + i.remove(); + tab.reset(); + releaseFromTabPool(tab); + } + + selectedTab = 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 androidx.viewpager.widget.ViewPager}. + *
+ * + * @param mode one of {@link #MODE_FIXED} or {@link #MODE_SCROLLABLE}. + * @attr ref com.google.android.material.R.styleable#TabLayout_tabMode + */ + public void setTabMode(@Mode int mode) { + if (mode != this.mode) { + this.mode = mode; + applyModeAndGravity(); + } + } + + /** + * Returns the current mode used by this {@link TabLayout}. + * + * @see #setTabMode(int) + */ + @Mode + public int getTabMode() { + return mode; + } + + /** + * Set the gravity to use when laying out the tabs. + * + * @param gravity one of {@link #GRAVITY_CENTER} or {@link #GRAVITY_FILL}. + * @attr ref com.google.android.material.R.styleable#TabLayout_tabGravity + */ + public void setTabGravity(@TabGravity int gravity) { + if (tabGravity != gravity) { + tabGravity = 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 tabGravity; + } + + /** + * Set the indicator gravity used to align the tab selection indicator in the {@link TabLayout}. + * You must set the indicator height via the custom indicator drawable's intrinsic height + * (preferred), via the {@code tabIndicatorHeight} attribute (deprecated), or via {@link + * #setSelectedTabIndicatorHeight(int)} (deprecated). Otherwise, the indicator will not be shown + * unless gravity is set to {@link #INDICATOR_GRAVITY_STRETCH}, in which case it will ignore + * indicator height and stretch across the entire height and width of the {@link TabLayout}. This + * defaults to {@link #INDICATOR_GRAVITY_BOTTOM} if not set. + * + * @param indicatorGravity one of {@link #INDICATOR_GRAVITY_BOTTOM}, {@link + * #INDICATOR_GRAVITY_CENTER}, {@link #INDICATOR_GRAVITY_TOP}, or {@link + * #INDICATOR_GRAVITY_STRETCH} + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorGravity + */ + public void setSelectedTabIndicatorGravity(@TabIndicatorGravity int indicatorGravity) { + if (tabIndicatorGravity != indicatorGravity) { + tabIndicatorGravity = indicatorGravity; + slidingTabIndicator.postInvalidateOnAnimation(); + } + } + + /** + * Get the current indicator gravity used to align the tab selection indicator in the {@link + * TabLayout}. + * + * @return one of {@link #INDICATOR_GRAVITY_BOTTOM}, {@link #INDICATOR_GRAVITY_CENTER}, {@link + * #INDICATOR_GRAVITY_TOP}, or {@link #INDICATOR_GRAVITY_STRETCH} + */ + @TabIndicatorGravity + public int getTabIndicatorGravity() { + return tabIndicatorGravity; + } + + /** + * Set the mode by which the selection indicator should animate when moving between destinations. + * + *

Defaults to {@link #INDICATOR_ANIMATION_MODE_LINEAR}. Changing this is useful as a stylistic + * choice. + * + * @param tabIndicatorAnimationMode one of {@link #INDICATOR_ANIMATION_MODE_LINEAR} or {@link + * #INDICATOR_ANIMATION_MODE_ELASTIC} + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode + * @see #getTabIndicatorAnimationMode() + */ + public void setTabIndicatorAnimationMode( + @TabIndicatorAnimationMode int tabIndicatorAnimationMode) { + this.tabIndicatorAnimationMode = tabIndicatorAnimationMode; + switch (tabIndicatorAnimationMode) { + case INDICATOR_ANIMATION_MODE_LINEAR: + this.tabIndicatorInterpolator = new TabIndicatorInterpolator(); + break; + case INDICATOR_ANIMATION_MODE_ELASTIC: + this.tabIndicatorInterpolator = new ElasticTabIndicatorInterpolator(); + break; + default: + throw new IllegalArgumentException( + tabIndicatorAnimationMode + " is not a valid TabIndicatorAnimationMode"); + } + } + + /** + * Get the current indicator animation mode used to animate the selection indicator between + * destinations. + * + * @return one of {@link #INDICATOR_ANIMATION_MODE_LINEAR} or {@link + * #INDICATOR_ANIMATION_MODE_ELASTIC} + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorAnimationMode + * @see #setTabIndicatorAnimationMode(int) + */ + @TabIndicatorAnimationMode + public int getTabIndicatorAnimationMode() { + return tabIndicatorAnimationMode; + } + + /** + * Enable or disable option to fit the tab selection indicator to the full width of the tab item + * rather than to the tab item's content. + * + *

Defaults to true. If set to false and the tab item has a text label, the selection indicator + * width will be set to the width of the text label. If the tab item has no text label, but does + * have an icon, the selection indicator width will be set to the icon. If the tab item has + * neither of these, or if the calculated width is less than a minimum width value, the selection + * indicator width will be set to the minimum width value. + * + * @param tabIndicatorFullWidth Whether or not to fit selection indicator width to full width of + * the tab item + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorFullWidth + * @see #isTabIndicatorFullWidth() + */ + public void setTabIndicatorFullWidth(boolean tabIndicatorFullWidth) { + this.tabIndicatorFullWidth = tabIndicatorFullWidth; + slidingTabIndicator.jumpIndicatorToSelectedPosition(); + slidingTabIndicator.postInvalidateOnAnimation(); + } + + /** + * Get whether or not selection indicator width is fit to full width of the tab item, or fit to + * the tab item's content. + * + * @return whether or not selection indicator width is fit to the full width of the tab item + * @attr ref com.google.android.material.R.styleable#TabLayout_tabIndicatorFullWidth + * @see #setTabIndicatorFullWidth(boolean) + */ + public boolean isTabIndicatorFullWidth() { + return tabIndicatorFullWidth; + } + + /** + * Set whether tab labels will be displayed inline with tab icons, or if they will be displayed + * underneath tab icons. + * + * @see #isInlineLabel() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabInlineLabel + */ + public void setInlineLabel(boolean inline) { + if (inlineLabel != inline) { + inlineLabel = inline; + for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) { + View child = slidingTabIndicator.getChildAt(i); + if (child instanceof TabView) { + ((TabView) child).updateOrientation(); + } + } + applyModeAndGravity(); + } + } + + /** + * Set whether tab labels will be displayed inline with tab icons, or if they will be displayed + * underneath tab icons. + * + * @param inlineResourceId Resource ID for boolean inline flag + * @see #isInlineLabel() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabInlineLabel + */ + public void setInlineLabelResource(@BoolRes int inlineResourceId) { + setInlineLabel(getResources().getBoolean(inlineResourceId)); + } + + /** + * Returns whether tab labels will be displayed inline with tab icons, or if they will be + * displayed underneath tab icons. + * + * @see #setInlineLabel(boolean) + * @attr ref com.google.android.material.R.styleable#TabLayout_tabInlineLabel + */ + public boolean isInlineLabel() { + return inlineLabel; + } + + /** + * Set whether this {@link TabLayout} will have an unbounded ripple effect or if ripple will be + * bound to the tab item size. + * + *

Defaults to false. + * + * @see #hasUnboundedRipple() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabUnboundedRipple + */ + public void setUnboundedRipple(boolean unboundedRipple) { + if (this.unboundedRipple != unboundedRipple) { + this.unboundedRipple = unboundedRipple; + for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) { + View child = slidingTabIndicator.getChildAt(i); + if (child instanceof TabView) { + ((TabView) child).updateBackgroundDrawable(getContext()); + } + } + } + } + + /** + * Set whether this {@link TabLayout} will have an unbounded ripple effect or if ripple will be + * bound to the tab item size. Defaults to false. + * + * @param unboundedRippleResourceId Resource ID for boolean unbounded ripple value + * @see #hasUnboundedRipple() + * @attr ref com.google.android.material.R.styleable#TabLayout_tabUnboundedRipple + */ + public void setUnboundedRippleResource(@BoolRes int unboundedRippleResourceId) { + setUnboundedRipple(getResources().getBoolean(unboundedRippleResourceId)); + } + + /** + * Returns whether this {@link TabLayout} has an unbounded ripple effect, or if ripple is bound to + * the tab item size. + * + * @see #setUnboundedRipple(boolean) + * @attr ref com.google.android.material.R.styleable#TabLayout_tabUnboundedRipple + */ + public boolean hasUnboundedRipple() { + return unboundedRipple; + } + + /** + * Sets the text colors for the different states (normal, selected) used for the tabs. + * + * @see #getTabTextColors() + */ + public void setTabTextColors(@Nullable ColorStateList textColor) { + if (tabTextColors != textColor) { + tabTextColors = textColor; + updateAllTabs(); + } + } + + /** Gets the text colors for the different states (normal, selected) used for the tabs. */ + @Nullable + public ColorStateList getTabTextColors() { + return tabTextColors; + } + + /** + * Sets the text colors for the different states (normal, selected) used for the tabs. + * + * @attr ref com.google.android.material.R.styleable#TabLayout_tabTextColor + * @attr ref com.google.android.material.R.styleable#TabLayout_tabSelectedTextColor + */ + public void setTabTextColors(int normalColor, int selectedColor) { + setTabTextColors(createColorStateList(normalColor, selectedColor)); + } + + public void setTabTextSize(float tabTextSize){ + this.tabTextSize=tabTextSize; + } + + /** + * Sets the icon tint for the different states (normal, selected) used for the tabs. + * + * @see #getTabIconTint() + */ + public void setTabIconTint(@Nullable ColorStateList iconTint) { + if (tabIconTint != iconTint) { + tabIconTint = iconTint; + updateAllTabs(); + } + } + + /** + * Sets the icon tint resource for the different states (normal, selected) used for the tabs. + * + * @param iconTintResourceId A color resource to use as icon tint. + * @see #getTabIconTint() + */ + public void setTabIconTintResource(@ColorRes int iconTintResourceId) { + setTabIconTint(getResources().getColorStateList(iconTintResourceId)); + } + + /** Gets the icon tint for the different states (normal, selected) used for the tabs. */ + @Nullable + public ColorStateList getTabIconTint() { + return tabIconTint; + } + + /** + * Returns the ripple color for this TabLayout. + * + * @return the color (or ColorStateList) used for the ripple + * @see #setTabRippleColor(ColorStateList) + */ + @Nullable + public ColorStateList getTabRippleColor() { + return tabRippleColorStateList; + } + + /** + * Sets the ripple color for this TabLayout. + * + *

When running on devices with KitKat or below, we draw this color as a filled overlay rather + * than a ripple. + * + * @param color color (or ColorStateList) to use for the ripple + * @attr ref com.google.android.material.R.styleable#TabLayout_tabRippleColor + * @see #getTabRippleColor() + */ + public void setTabRippleColor(@Nullable ColorStateList color) { + if (tabRippleColorStateList != color) { + tabRippleColorStateList = color; + for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) { + View child = slidingTabIndicator.getChildAt(i); + if (child instanceof TabView) { + ((TabView) child).updateBackgroundDrawable(getContext()); + } + } + } + } + + /** + * Sets the ripple color resource for this TabLayout. + * + *

When running on devices with KitKat or below, we draw this color as a filled overlay rather + * than a ripple. + * + * @param tabRippleColorResourceId A color resource to use as ripple color. + * @see #getTabRippleColor() + */ + public void setTabRippleColorResource(@ColorRes int tabRippleColorResourceId) { + setTabRippleColor(getResources().getColorStateList(tabRippleColorResourceId)); + } + + /** + * Returns the selection indicator drawable for this TabLayout. + * + * @return The drawable used as the tab selection indicator, if set. + * @see #setSelectedTabIndicator(Drawable) + * @see #setSelectedTabIndicator(int) + */ + @NonNull + public Drawable getTabSelectedIndicator() { + return tabSelectedIndicator; + } + + /** + * Sets the selection indicator for this TabLayout. By default, this is a line along the bottom of + * the tab. If {@code tabIndicatorColor} is specified via the TabLayout's style or via {@link + * #setSelectedTabIndicatorColor(int)} the selection indicator will be tinted that color. + * Otherwise, it will use the colors specified in the drawable. + * + *

Setting the indicator drawable to null will cause {@link TabLayout} to use the default, + * {@link GradientDrawable} line indicator. + * + * @param tabSelectedIndicator A drawable to use as the selected tab indicator. + * @see #setSelectedTabIndicatorColor(int) + * @see #setSelectedTabIndicator(int) + */ + public void setSelectedTabIndicator(@Nullable Drawable tabSelectedIndicator) { + if (this.tabSelectedIndicator != tabSelectedIndicator) { + this.tabSelectedIndicator = + tabSelectedIndicator != null ? tabSelectedIndicator : new GradientDrawable(); + int indicatorHeight = + tabIndicatorHeight != -1 + ? tabIndicatorHeight + : this.tabSelectedIndicator.getIntrinsicHeight(); + slidingTabIndicator.setSelectedIndicatorHeight(indicatorHeight); + } + } + + /** + * Sets the drawable resource to use as the selection indicator for this TabLayout. By default, + * this is a line along the bottom of the tab. If {@code tabIndicatorColor} is specified via the + * TabLayout's style or via {@link #setSelectedTabIndicatorColor(int)} the selection indicator + * will be tinted that color. Otherwise, it will use the colors specified in the drawable. + * + * @param tabSelectedIndicatorResourceId A drawable resource to use as the selected tab indicator. + * @see #setSelectedTabIndicatorColor(int) + * @see #setSelectedTabIndicator(Drawable) + */ + public void setSelectedTabIndicator(@DrawableRes int tabSelectedIndicatorResourceId) { + if (tabSelectedIndicatorResourceId != 0) { + setSelectedTabIndicator( + getResources().getDrawable(tabSelectedIndicatorResourceId)); + } else { + setSelectedTabIndicator(null); + } + } + + /** + * The one-stop shop for setting up this {@link TabLayout} with a {@link ViewPager}. + * + *

This is the same as calling {@link #setupWithViewPager(ViewPager, boolean)} with + * auto-refresh enabled. + * + * @param viewPager the ViewPager to link to, or {@code null} to clear any previous link + */ + public void setupWithViewPager(@Nullable ViewPager viewPager) { + setupWithViewPager(viewPager, true); + } + + /** + * The one-stop shop for setting up this {@link TabLayout} with a {@link ViewPager}. + * + *

This method will link the given ViewPager and this TabLayout together so that changes in one + * are automatically reflected in the other. This includes scroll state changes and clicks. The + * tabs displayed in this layout will be populated from the ViewPager adapter's page titles. + * + *

If {@code autoRefresh} is {@code true}, any changes in the {@link PagerAdapter} will trigger + * this layout to re-populate itself from the adapter's titles. + * + *

If the given ViewPager is non-null, it needs to already have a {@link PagerAdapter} set. + * + * @param viewPager the ViewPager to link to, or {@code null} to clear any previous link + * @param autoRefresh whether this layout should refresh its contents if the given ViewPager's + * content changes + */ + public void setupWithViewPager(@Nullable final ViewPager viewPager, boolean autoRefresh) { + setupWithViewPager(viewPager, autoRefresh, false); + } + + private void setupWithViewPager( + @Nullable final ViewPager viewPager, boolean autoRefresh, boolean implicitSetup) { + if (this.viewPager != null) { + // If we've already been setup with a ViewPager, remove us from it + if (pageChangeListener != null) { + this.viewPager.removeOnPageChangeListener(pageChangeListener); + } + if (adapterChangeListener != null) { + this.viewPager.removeOnAdapterChangeListener(adapterChangeListener); + } + } + + if (currentVpSelectedListener != null) { + // If we already have a tab selected listener for the ViewPager, remove it + removeOnTabSelectedListener(currentVpSelectedListener); + currentVpSelectedListener = null; + } + + if (viewPager != null) { + this.viewPager = viewPager; + + // Add our custom OnPageChangeListener to the ViewPager + if (pageChangeListener == null) { + pageChangeListener = new TabLayoutOnPageChangeListener(this); + } + pageChangeListener.reset(); + viewPager.addOnPageChangeListener(pageChangeListener); + + // Now we'll add a tab selected listener to set ViewPager's current item + currentVpSelectedListener = new ViewPagerOnTabSelectedListener(viewPager); + addOnTabSelectedListener(currentVpSelectedListener); + + final PagerAdapter adapter = viewPager.getAdapter(); + if (adapter != null) { + // Now we'll populate ourselves from the pager adapter, adding an observer if + // autoRefresh is enabled + setPagerAdapter(adapter, autoRefresh); + } + + // Add a listener so that we're notified of any adapter changes + if (adapterChangeListener == null) { + adapterChangeListener = new AdapterChangeListener(); + } + adapterChangeListener.setAutoRefresh(autoRefresh); + viewPager.addOnAdapterChangeListener(adapterChangeListener); + + // Now update the scroll position to match the ViewPager's current item + setScrollPosition(viewPager.getCurrentItem(), 0f, true); + } else { + // We've been given a null ViewPager so we need to clear out the internal state, + // listeners and observers + this.viewPager = null; + setPagerAdapter(null, false); + } + + setupViewPagerImplicitly = implicitSetup; + } + + /** + * @deprecated Use {@link #setupWithViewPager(ViewPager)} to link a TabLayout with a ViewPager + * together. When that method is used, the TabLayout will be automatically updated when the + * {@link PagerAdapter} is changed. + */ + @Deprecated + public void setTabsFromPagerAdapter(@Nullable final PagerAdapter adapter) { + setPagerAdapter(adapter, false); + } + + @Override + public boolean shouldDelayChildPressedState() { + // Only delay the pressed state if the tabs can scroll + return getTabScrollRange() > 0; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + +// MaterialShapeUtils.setParentAbsoluteElevation(this); + + if (viewPager == null) { + // If we don't have a ViewPager already, check if our parent is a ViewPager to + // setup with it automatically + final ViewParent vp = getParent(); + if (vp instanceof ViewPager) { + // If we have a ViewPager parent and we've been added as part of its decor, let's + // assume that we should automatically setup to display any titles + setupWithViewPager((ViewPager) vp, true, true); + } + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (setupViewPagerImplicitly) { + // If we've been setup with a ViewPager implicitly, let's clear out any listeners, etc + setupWithViewPager(null); + setupViewPagerImplicitly = false; + } + } + + private int getTabScrollRange() { + return Math.max( + 0, slidingTabIndicator.getWidth() - getWidth() - getPaddingLeft() - getPaddingRight()); + } + + void setPagerAdapter(@Nullable final PagerAdapter adapter, final boolean addObserver) { + if (pagerAdapter != null && pagerAdapterObserver != null) { + // If we already have a PagerAdapter, unregister our observer + pagerAdapter.unregisterDataSetObserver(pagerAdapterObserver); + } + + pagerAdapter = adapter; + + if (addObserver && adapter != null) { + // Register our observer on the new adapter + if (pagerAdapterObserver == null) { + pagerAdapterObserver = new PagerAdapterObserver(); + } + adapter.registerDataSetObserver(pagerAdapterObserver); + } + + // Finally make sure we reflect the new adapter + populateFromPagerAdapter(); + } + + void populateFromPagerAdapter() { + removeAllTabs(); + + if (pagerAdapter != null) { + final int adapterCount = pagerAdapter.getCount(); + for (int i = 0; i < adapterCount; i++) { + addTab(newTab().setText(pagerAdapter.getPageTitle(i)), false); + } + + // Make sure we reflect the currently set ViewPager item + if (viewPager != null && adapterCount > 0) { + final int curItem = viewPager.getCurrentItem(); + if (curItem != getSelectedTabPosition() && curItem < getTabCount()) { + selectTab(getTabAt(curItem)); + } + } + } + } + + private void updateAllTabs() { + for (int i = 0, z = tabs.size(); i < z; i++) { + tabs.get(i).updateView(); + } + } + + @NonNull + private TabView createTabView(@NonNull final Tab tab) { + TabView tabView = tabViewPool != null ? tabViewPool.acquire() : null; + if (tabView == null) { + tabView = new TabView(getContext()); + } + tabView.setTab(tab); + tabView.setFocusable(true); + tabView.setMinimumWidth(getTabMinWidth()); + if (TextUtils.isEmpty(tab.contentDesc)) { + tabView.setContentDescription(tab.text); + } else { + tabView.setContentDescription(tab.contentDesc); + } + return tabView; + } + + private void configureTab(@NonNull Tab tab, int position) { + tab.setPosition(position); + tabs.add(position, tab); + + final int count = tabs.size(); + for (int i = position + 1; i < count; i++) { + tabs.get(i).setPosition(i); + } + } + + private void addTabView(@NonNull Tab tab) { + final TabView tabView = tab.view; + tabView.setSelected(false); + tabView.setActivated(false); + slidingTabIndicator.addView(tabView, tab.getPosition(), createLayoutParamsForTabs()); + } + + @Override + public void addView(View child) { + addViewInternal(child); + } + + @Override + public void addView(View child, int index) { + addViewInternal(child); + } + + @Override + public void addView(View child, ViewGroup.LayoutParams params) { + addViewInternal(child); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + addViewInternal(child); + } + + private void addViewInternal(final View child) { + if (child instanceof TabItem) { + addTabFromItemView((TabItem) child); + } else { + throw new IllegalArgumentException("Only TabItem instances can be added to TabLayout"); + } + } + + @NonNull + private LinearLayout.LayoutParams createLayoutParamsForTabs() { + final LinearLayout.LayoutParams lp = + new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); + updateTabViewLayoutParams(lp); + return lp; + } + + private void updateTabViewLayoutParams(@NonNull LinearLayout.LayoutParams lp) { + if (mode == MODE_FIXED && tabGravity == GRAVITY_FILL) { + lp.width = 0; + lp.weight = 1; + } else { + lp.width = LinearLayout.LayoutParams.WRAP_CONTENT; + lp.weight = 0; + } + } + + @RequiresApi(VERSION_CODES.LOLLIPOP) + @Override + public void setElevation(float elevation) { + super.setElevation(elevation); + +// MaterialShapeUtils.setElevation(this, elevation); + } + + @Override + public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setCollectionInfo( + AccessibilityNodeInfo.CollectionInfo.obtain( + /* rowCount= */ 1, + /* columnCount= */ getTabCount(), + /* hierarchical= */ false, + /* selectionMode = */ AccessibilityNodeInfo.CollectionInfo.SELECTION_MODE_SINGLE)); + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + // Draw tab background layer for each tab item + for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) { + View tabView = slidingTabIndicator.getChildAt(i); + if (tabView instanceof TabView) { + ((TabView) tabView).drawBackground(canvas); + } + } + + super.onDraw(canvas); + } + + @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 = Math.round(V.dp(getDefaultHeight())); + switch (MeasureSpec.getMode(heightMeasureSpec)) { + case MeasureSpec.AT_MOST: + if (getChildCount() == 1 && MeasureSpec.getSize(heightMeasureSpec) >= idealHeight) { + getChildAt(0).setMinimumHeight(idealHeight); + } + break; + case MeasureSpec.UNSPECIFIED: + heightMeasureSpec = + MeasureSpec.makeMeasureSpec( + idealHeight + getPaddingTop() + getPaddingBottom(), MeasureSpec.EXACTLY); + break; + default: + break; + } + + final int specWidth = MeasureSpec.getSize(widthMeasureSpec); + if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) { + // If we don't have an unspecified width spec, use the given size to calculate + // the max tab width + tabMaxWidth = + requestedTabMaxWidth > 0 + ? requestedTabMaxWidth + : (int) (specWidth - V.dp(TAB_MIN_WIDTH_MARGIN)); + } + + // Now super measure itself using the (possibly) modified height spec + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (getChildCount() == 1) { + // If we're in fixed mode then we need to make sure the tab strip is the same width as us + // so we don't scroll + final View child = getChildAt(0); + boolean remeasure = false; + + switch (mode) { + case MODE_AUTO: + case MODE_SCROLLABLE: + // We only need to resize the child if it's smaller than us. This is similar + // to fillViewport + remeasure = child.getMeasuredWidth() < getMeasuredWidth(); + break; + case MODE_FIXED: + // Resize the child so that it doesn't scroll + remeasure = child.getMeasuredWidth() != getMeasuredWidth(); + break; + } + + if (remeasure) { + // Re-measure the child with a widthSpec set to be exactly our measure width + int childHeightMeasureSpec = + getChildMeasureSpec( + heightMeasureSpec, + getPaddingTop() + getPaddingBottom(), + child.getLayoutParams().height); + + int childWidthMeasureSpec = + MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY); + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + } + } + + private void removeTabViewAt(int position) { + final TabView view = (TabView) slidingTabIndicator.getChildAt(position); + slidingTabIndicator.removeViewAt(position); + if (view != null) { + view.reset(); + tabViewPool.release(view); + } + requestLayout(); + } + + private void animateToTab(int newPosition) { + if (newPosition == Tab.INVALID_POSITION) { + return; + } + + if (getWindowToken() == null + || !isLaidOut() + || slidingTabIndicator.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) { + ensureScrollAnimator(); + + scrollAnimator.setIntValues(startScrollX, targetScrollX); + scrollAnimator.start(); + } + + // Now animate the indicator + slidingTabIndicator.animateIndicatorToPosition(newPosition, tabIndicatorAnimationDuration); + } + + private void ensureScrollAnimator() { + if (scrollAnimator == null) { + scrollAnimator = new ValueAnimator(); + scrollAnimator.setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR); + scrollAnimator.setDuration(tabIndicatorAnimationDuration); + scrollAnimator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(@NonNull ValueAnimator animator) { + scrollTo((int) animator.getAnimatedValue(), 0); + } + }); + } + } + + void setScrollAnimatorListener(ValueAnimator.AnimatorListener listener) { + ensureScrollAnimator(); + scrollAnimator.addListener(listener); + } + + /** + * Called when a selected tab is added. Unselects all other tabs in the TabLayout. + * + * @param position Position of the selected tab. + */ + private void setSelectedTabView(int position) { + final int tabCount = slidingTabIndicator.getChildCount(); + if (position < tabCount) { + for (int i = 0; i < tabCount; i++) { + final View child = slidingTabIndicator.getChildAt(i); + child.setSelected(i == position); + child.setActivated(i == position); + } + } + } + + /** + * Selects the given tab. + * + * @param tab The tab to select, or {@code null} to select none. + * @see #selectTab(Tab, boolean) + */ + public void selectTab(@Nullable Tab tab) { + selectTab(tab, true); + } + + /** + * Selects the given tab. Will always animate to the selected tab if the current tab is + * reselected, regardless of the value of {@code updateIndicator}. + * + * @param tab The tab to select, or {@code null} to select none. + * @param updateIndicator Whether to animate to the selected tab. + * @see #selectTab(Tab) + */ + public void selectTab(@Nullable final Tab tab, boolean updateIndicator) { + final Tab currentTab = selectedTab; + + if (currentTab == tab) { + if (currentTab != null) { + dispatchTabReselected(tab); + animateToTab(tab.getPosition()); + } + } else { + final int newPosition = tab != null ? tab.getPosition() : Tab.INVALID_POSITION; + if (updateIndicator) { + if ((currentTab == null || currentTab.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 (newPosition != Tab.INVALID_POSITION) { + setSelectedTabView(newPosition); + } + } + // Setting selectedTab before dispatching 'tab unselected' events, so that currentTab's state + // will be interpreted as unselected + selectedTab = tab; + if (currentTab != null) { + dispatchTabUnselected(currentTab); + } + if (tab != null) { + dispatchTabSelected(tab); + } + } + } + + private void dispatchTabSelected(@NonNull final Tab tab) { + for (int i = selectedListeners.size() - 1; i >= 0; i--) { + selectedListeners.get(i).onTabSelected(tab); + } + } + + private void dispatchTabUnselected(@NonNull final Tab tab) { + for (int i = selectedListeners.size() - 1; i >= 0; i--) { + selectedListeners.get(i).onTabUnselected(tab); + } + } + + private void dispatchTabReselected(@NonNull final Tab tab) { + for (int i = selectedListeners.size() - 1; i >= 0; i--) { + selectedListeners.get(i).onTabReselected(tab); + } + } + + private int calculateScrollXForTab(int position, float positionOffset) { + if (mode == MODE_SCROLLABLE || mode == MODE_AUTO) { + final View selectedChild = slidingTabIndicator.getChildAt(position); + if (selectedChild == null) { + return 0; + } + final View nextChild = + position + 1 < slidingTabIndicator.getChildCount() + ? slidingTabIndicator.getChildAt(position + 1) + : null; + final int selectedWidth = selectedChild.getWidth(); + final int nextWidth = nextChild != null ? nextChild.getWidth() : 0; + + // base scroll amount: places center of tab in center of parent + int scrollBase = selectedChild.getLeft() + (selectedWidth / 2) - (getWidth() / 2); + // offset amount: fraction of the distance between centers of tabs + int scrollOffset = (int) ((selectedWidth + nextWidth) * 0.5f * positionOffset); + + return (getLayoutDirection() == LAYOUT_DIRECTION_LTR) + ? scrollBase + scrollOffset + : scrollBase - scrollOffset; + } + return 0; + } + + private void applyModeAndGravity() { + int paddingStart = 0; + if (mode == MODE_SCROLLABLE || mode == MODE_AUTO) { + // If we're scrollable, or fixed at start, inset using padding + paddingStart = Math.max(0, contentInsetStart - tabPaddingStart); + } + slidingTabIndicator.setPaddingRelative(paddingStart, 0, 0, 0); + + switch (mode) { + case MODE_AUTO: + case MODE_FIXED: + if (tabGravity == GRAVITY_START) { + Log.w( + LOG_TAG, + "GRAVITY_START is not supported with the current tab mode, GRAVITY_CENTER will be" + + " used instead"); + } + slidingTabIndicator.setGravity(Gravity.CENTER_HORIZONTAL); + break; + case MODE_SCROLLABLE: + applyGravityForModeScrollable(tabGravity); + break; + } + + updateTabViews(true); + } + + private void applyGravityForModeScrollable(int tabGravity) { + switch (tabGravity) { + case GRAVITY_CENTER: + slidingTabIndicator.setGravity(Gravity.CENTER_HORIZONTAL); + break; + case GRAVITY_FILL: + Log.w( + LOG_TAG, + "MODE_SCROLLABLE + GRAVITY_FILL is not supported, GRAVITY_START will be used" + + " instead"); + // Fall through + case GRAVITY_START: + slidingTabIndicator.setGravity(Gravity.START); + break; + default: + break; + } + } + + void updateTabViews(final boolean requestLayout) { + for (int i = 0; i < slidingTabIndicator.getChildCount(); i++) { + View child = slidingTabIndicator.getChildAt(i); + child.setMinimumWidth(getTabMinWidth()); + updateTabViewLayoutParams((LinearLayout.LayoutParams) child.getLayoutParams()); + if (requestLayout) { + child.requestLayout(); + } + } + } + + /** A tab in this layout. Instances can be created via {@link #newTab()}. */ + // TODO(b/76413401): make class final after the widget migration is finished + public static class Tab { + + /** + * An invalid position for a tab. + * + * @see #getPosition() + */ + public static final int INVALID_POSITION = -1; + + @Nullable private Object tag; + @Nullable private Drawable icon; + @Nullable private CharSequence text; + // This represents the content description that has been explicitly set on the Tab or TabItem + // in XML or through #setContentDescription. If the content description is empty, text should + // be used as the content description instead, but contentDesc should remain empty. + @Nullable private CharSequence contentDesc; + private int position = INVALID_POSITION; + @Nullable private View customView; + private @LabelVisibility int labelVisibilityMode = TAB_LABEL_VISIBILITY_LABELED; + + // TODO(b/76413401): make package private after the widget migration is finished + @Nullable public TabLayout parent; + // TODO(b/76413401): make package private after the widget migration is finished + @NonNull public TabView view; + private int id = NO_ID; + + // TODO(b/76413401): make package private constructor after the widget migration is finished + public Tab() { + // Private constructor + } + + /** @return This Tab's tag object. */ + @Nullable + public Object getTag() { + return tag; + } + + /** + * 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) { + this.tag = tag; + return this; + } + + /** + * Give this tab an id, useful for testing. + * + *

Do not rely on this if using {@link TabLayout#setupWithViewPager(ViewPager)} + * + * @param id, unique id for this tab + */ + @NonNull + public Tab setId(int id) { + this.id = id; + if (view != null) { + view.setId(id); + } + return this; + } + + /** Returns the id for this tab, {@code View.NO_ID} if not set. */ + public int getId() { + return id; + } + + /** + * Returns the custom view used for this tab. + * + * @see #setCustomView(View) + * @see #setCustomView(int) + */ + @Nullable + public View getCustomView() { + return customView; + } + + /** + * 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) { + customView = view; + updateView(); + 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 resId 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 resId) { + final LayoutInflater inflater = LayoutInflater.from(view.getContext()); + return setCustomView(inflater.inflate(resId, view, false)); + } + + /** + * Return the icon associated with this tab. + * + * @return The tab's icon + */ + @Nullable + public Drawable getIcon() { + return icon; + } + + /** + * 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 position; + } + + void setPosition(int position) { + this.position = position; + } + + /** + * Return the text of this tab. + * + * @return The tab's text + */ + @Nullable + public CharSequence getText() { + return text; + } + + /** + * 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) { + this.icon = icon; + if ((parent.tabGravity == GRAVITY_CENTER) || parent.mode == MODE_AUTO) { + parent.updateTabViews(true); + } + updateView(); +// if (BadgeUtils.USE_COMPAT_PARENT +// && view.hasBadgeDrawable() +// && view.badgeDrawable.isVisible()) { +// // Invalidate the TabView if icon visibility has changed and a badge is displayed. +// view.invalidate(); +// } + 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) { + if (parent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + return setIcon(parent.getContext().getDrawable(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) { + if (TextUtils.isEmpty(contentDesc) && !TextUtils.isEmpty(text)) { + // If no content description has been set, use the text as the content description of the + // TabView. If the text is null, don't update the content description. + view.setContentDescription(text); + } + + this.text = text; + updateView(); + 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) { + if (parent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + return setText(parent.getResources().getText(resId)); + } + + /** +// * Creates an instance of {@link BadgeDrawable} if none exists. Initializes (if needed) and +// * returns the associated instance of {@link BadgeDrawable}. +// * +// * @return an instance of BadgeDrawable associated with {@code Tab}. +// */ +// @NonNull +// public BadgeDrawable getOrCreateBadge() { +// return view.getOrCreateBadge(); +// } +// +// /** +// * Removes the {@link BadgeDrawable}. Do nothing if none exists. Consider changing the +// * visibility of the {@link BadgeDrawable} if you only want to hide it temporarily. +// */ +// public void removeBadge() { +// view.removeBadge(); +// } +// +// /** +// * Returns an instance of {@link BadgeDrawable} associated with this tab, null if none was +// * initialized. +// */ +// @Nullable +// public BadgeDrawable getBadge() { +// return view.getBadge(); +// } + + /** + * Sets the visibility mode for the Labels in this Tab. The valid input options are: + * + *

    + *
  • {@link #TAB_LABEL_VISIBILITY_UNLABELED}: Tabs will appear without labels regardless of + * whether text is set. + *
  • {@link #TAB_LABEL_VISIBILITY_LABELED}: Tabs will appear labeled if text is set. + *
+ * + * @param mode one of {@link #TAB_LABEL_VISIBILITY_UNLABELED} or {@link + * #TAB_LABEL_VISIBILITY_LABELED}. + * @return The current instance for call chaining. + */ + @NonNull + public Tab setTabLabelVisibility(@LabelVisibility int mode) { + this.labelVisibilityMode = mode; + if ((parent.tabGravity == GRAVITY_CENTER) || parent.mode == MODE_AUTO) { + parent.updateTabViews(true); + } + this.updateView(); +// if (BadgeUtils.USE_COMPAT_PARENT +// && view.hasBadgeDrawable() +// && view.badgeDrawable.isVisible()) { +// // Invalidate the TabView if label visibility has changed and a badge is displayed. +// view.invalidate(); +// } + return this; + } + + /** + * Gets the visibility mode for the Labels in this Tab. + * + * @return the label visibility mode, one of {@link #TAB_LABEL_VISIBILITY_UNLABELED} or {@link + * #TAB_LABEL_VISIBILITY_LABELED}. + * @see #setTabLabelVisibility(int) + */ + @LabelVisibility + public int getTabLabelVisibility() { + return this.labelVisibilityMode; + } + + /** Select this tab. Only valid if the tab has been added to the action bar. */ + public void select() { + if (parent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + parent.selectTab(this); + } + + /** Returns true if this tab is currently selected. */ + public boolean isSelected() { + if (parent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + int selectedPosition = parent.getSelectedTabPosition(); + return selectedPosition != INVALID_POSITION && selectedPosition == position; + } + + /** + * 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) { + if (parent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + return setContentDescription(parent.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) { + this.contentDesc = contentDesc; + updateView(); + 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() { + // This returns the view's content description instead of contentDesc because if the title + // is used as a replacement for the content description, contentDesc will be empty. + return (view == null) ? null : view.getContentDescription(); + } + + void updateView() { + if (view != null) { + view.update(); + } + } + + void reset() { + parent = null; + view = null; + tag = null; + icon = null; + id = NO_ID; + text = null; + contentDesc = null; + position = INVALID_POSITION; + customView = null; + } + } + + /** A {@link LinearLayout} containing {@link Tab} instances for use with {@link TabLayout}. */ + public final class TabView extends LinearLayout { + private Tab tab; + private TextView textView; + private ImageView iconView; + @Nullable private View badgeAnchorView; +// @Nullable private BadgeDrawable badgeDrawable; + + @Nullable private View customView; + @Nullable private TextView customTextView; + @Nullable private ImageView customIconView; + @Nullable private Drawable baseBackgroundDrawable; + + private int defaultMaxLines = 2; + + public TabView(@NonNull Context context) { + super(context); + updateBackgroundDrawable(context); + setPaddingRelative(tabPaddingStart, tabPaddingTop, tabPaddingEnd, tabPaddingBottom); + setGravity(Gravity.CENTER); + setOrientation(inlineLabel ? HORIZONTAL : VERTICAL); + setClickable(true); + if(VERSION.SDK_INT >= VERSION_CODES.N){ + setPointerIcon(PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND)); + } + } + + private void updateBackgroundDrawable(Context context) { + if (tabBackgroundResId != 0) { + baseBackgroundDrawable = context.getDrawable(tabBackgroundResId); + if (baseBackgroundDrawable != null && baseBackgroundDrawable.isStateful()) { + baseBackgroundDrawable.setState(getDrawableState()); + } + } else { + baseBackgroundDrawable = null; + } + + Drawable background; + Drawable contentDrawable = new GradientDrawable(); + ((GradientDrawable) contentDrawable).setColor(Color.TRANSPARENT); + + if (tabRippleColorStateList != null) { + GradientDrawable maskDrawable = new GradientDrawable(); + // TODO: Find a workaround for this. Currently on certain devices/versions, + // LayerDrawable will draw a black background underneath any layer with a non-opaque color, + // (e.g. ripple) unless we set the shape to be something that's not a perfect rectangle. + maskDrawable.setCornerRadius(0.00001F); + maskDrawable.setColor(Color.WHITE); + + ColorStateList rippleColor = + /*RippleUtils.convertToRippleDrawableColor(*/tabRippleColorStateList/*)*/; + + // TODO: Add support to RippleUtils.compositeRippleColorStateList for different ripple color + // for selected items vs non-selected items + background = + new RippleDrawable( + rippleColor, + unboundedRipple ? null : contentDrawable, + unboundedRipple ? null : maskDrawable); + } else { + background = contentDrawable; + } + setBackground(background); + TabLayout.this.invalidate(); + } + + /** + * Draw the background drawable specified by tabBackground attribute onto the canvas provided. + * This method will draw the background to the full bounds of this TabView. We provide a + * separate method for drawing this background rather than just setting this background on the + * TabView so that we can control when this background gets drawn. This allows us to draw the + * tab background underneath the TabLayout selection indicator, and then draw the TabLayout + * content (icons + labels) on top of the selection indicator. + * + * @param canvas canvas to draw the background on + */ + private void drawBackground(@NonNull Canvas canvas) { + if (baseBackgroundDrawable != null) { + baseBackgroundDrawable.setBounds(getLeft(), getTop(), getRight(), getBottom()); + baseBackgroundDrawable.draw(canvas); + } + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + boolean changed = false; + int[] state = getDrawableState(); + if (baseBackgroundDrawable != null && baseBackgroundDrawable.isStateful()) { + changed |= baseBackgroundDrawable.setState(state); + } + + if (changed) { + invalidate(); + TabLayout.this.invalidate(); // Invalidate TabLayout, which draws mBaseBackgroundDrawable + } + } + + @Override + public boolean performClick() { + final boolean handled = super.performClick(); + + if (tab != null) { + if (!handled) { + playSoundEffect(SoundEffectConstants.CLICK); + } + tab.select(); + return true; + } else { + return handled; + } + } + + @Override + public void setSelected(final boolean selected) { + final boolean changed = isSelected() != selected; + + super.setSelected(selected); + + if (changed && selected && VERSION.SDK_INT < 16) { + // Pre-JB we need to manually send the TYPE_VIEW_SELECTED event + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); + } + + // Always dispatch this to the child views, regardless of whether the value has + // changed + if (textView != null) { + textView.setSelected(selected); + } + if (iconView != null) { + iconView.setSelected(selected); + } + if (customView != null) { + customView.setSelected(selected); + } + } + + @Override + public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); +// if (badgeDrawable != null && badgeDrawable.isVisible()) { +// CharSequence customContentDescription = getContentDescription(); +// info.setContentDescription( +// customContentDescription + ", " + badgeDrawable.getContentDescription()); +// } + info.setCollectionItemInfo( + AccessibilityNodeInfo.CollectionItemInfo.obtain( + /* rowIndex= */ 0, + /* rowSpan= */ 1, + /* columnIndex= */ tab.getPosition(), + /* columnSpan= */ 1, + /* heading= */ false, + /* selected= */ isSelected())); + if (isSelected()) { + info.setClickable(false); + info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK); + } +// info.setRoleDescription(getResources().getString(R.string.item_view_role_description)); + } + + @Override + public void onMeasure(final int origWidthMeasureSpec, final int origHeightMeasureSpec) { + final int specWidthSize = MeasureSpec.getSize(origWidthMeasureSpec); + final int specWidthMode = MeasureSpec.getMode(origWidthMeasureSpec); + final int maxWidth = getTabMaxWidth(); + + final int widthMeasureSpec; + final int heightMeasureSpec = origHeightMeasureSpec; + + if (maxWidth > 0 && (specWidthMode == MeasureSpec.UNSPECIFIED || specWidthSize > maxWidth)) { + // If we have a max width and a given spec which is either unspecified or + // larger than the max width, update the width spec using the same mode + widthMeasureSpec = MeasureSpec.makeMeasureSpec(tabMaxWidth, MeasureSpec.AT_MOST); + } else { + // Else, use the original width spec + widthMeasureSpec = origWidthMeasureSpec; + } + + // Now lets measure + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + // We need to switch the text size based on whether the text is spanning 2 lines or not + if (textView != null) { + float textSize = tabTextSize; + int maxLines = defaultMaxLines; + + if (iconView != null && iconView.getVisibility() == VISIBLE) { + // If the icon view is being displayed, we limit the text to 1 line + maxLines = 1; + } else if (textView != null && textView.getLineCount() > 1) { + // Otherwise when we have text which wraps we reduce the text size + textSize = tabTextMultiLineSize; + } + + final float curTextSize = textView.getTextSize(); + final int curLineCount = textView.getLineCount(); + final int curMaxLines = textView.getMaxLines(); + + if (textSize != curTextSize || (curMaxLines >= 0 && maxLines != curMaxLines)) { + // We've got a new text size and/or max lines... + boolean updateTextView = true; + + if (mode == MODE_FIXED && textSize > curTextSize && curLineCount == 1) { + // If we're in fixed mode, going up in text size and currently have 1 line + // then it's very easy to get into an infinite recursion. + // To combat that we check to see if the change in text size + // will cause a line count change. If so, abort the size change and stick + // to the smaller size. + final Layout layout = textView.getLayout(); + if (layout == null + || approximateLineWidth(layout, 0, textSize) + > getMeasuredWidth() - getPaddingLeft() - getPaddingRight()) { + updateTextView = false; + } + } + + if (updateTextView) { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + textView.setMaxLines(maxLines); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + } + } + + void setTab(@Nullable final Tab tab) { + if (tab != this.tab) { + this.tab = tab; + update(); + } + } + + void reset() { + setTab(null); + setSelected(false); + } + + final void update() { + final Tab tab = this.tab; + final View custom = tab != null ? tab.getCustomView() : null; + if (custom != null) { + final ViewParent customParent = custom.getParent(); + if (customParent != this) { + if (customParent != null) { + ((ViewGroup) customParent).removeView(custom); + } + addView(custom); + } + customView = custom; + if (this.textView != null) { + this.textView.setVisibility(GONE); + } + if (this.iconView != null) { + this.iconView.setVisibility(GONE); + this.iconView.setImageDrawable(null); + } + + customTextView = custom.findViewById(android.R.id.text1); + if (customTextView != null) { + defaultMaxLines = customTextView.getMaxLines(); + } + customIconView = custom.findViewById(android.R.id.icon); + } else { + // We do not have a custom view. Remove one if it already exists + if (customView != null) { + removeView(customView); + customView = null; + } + customTextView = null; + customIconView = null; + } + + if (customView == null) { + // If there isn't a custom view, we'll us our own in-built layouts + if (this.iconView == null) { + inflateAndAddDefaultIconView(); + } + if (this.textView == null) { + inflateAndAddDefaultTextView(); + defaultMaxLines = this.textView.getMaxLines(); + } + this.textView.setTextAppearance(tabTextAppearance); + if (tabTextColors != null) { + this.textView.setTextColor(tabTextColors); + } + updateTextAndIcon(this.textView, this.iconView); + +// tryUpdateBadgeAnchor(); +// addOnLayoutChangeListener(iconView); +// addOnLayoutChangeListener(textView); + } else { + // Else, we'll see if there is a TextView or ImageView present and update them + if (customTextView != null || customIconView != null) { + updateTextAndIcon(customTextView, customIconView); + } + } + + if (tab != null && !TextUtils.isEmpty(tab.contentDesc)) { + // Only update the TabView's content description from Tab if the Tab's content description + // has been explicitly set. + setContentDescription(tab.contentDesc); + } + // Finally update our selected state + setSelected(tab != null && tab.isSelected()); + } + + private void inflateAndAddDefaultIconView() { + ViewGroup iconViewParent = this; +// if (BadgeUtils.USE_COMPAT_PARENT) { +// iconViewParent = createPreApi18BadgeAnchorRoot(); +// addView(iconViewParent, 0); +// } + this.iconView = + (ImageView) + LayoutInflater.from(getContext()) + .inflate(R.layout.design_layout_tab_icon, iconViewParent, false); + iconViewParent.addView(iconView, 0); + } + + private void inflateAndAddDefaultTextView() { + ViewGroup textViewParent = this; +// if (BadgeUtils.USE_COMPAT_PARENT) { +// textViewParent = createPreApi18BadgeAnchorRoot(); +// addView(textViewParent); +// } + this.textView = + (TextView) + LayoutInflater.from(getContext()) + .inflate(R.layout.design_layout_tab_text, textViewParent, false); + textViewParent.addView(textView); + } + + @NonNull + private FrameLayout createPreApi18BadgeAnchorRoot() { + FrameLayout frameLayout = new FrameLayout(getContext()); + FrameLayout.LayoutParams layoutparams = + new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + frameLayout.setLayoutParams(layoutparams); + return frameLayout; + } + +// /** +// * Creates an instance of {@link BadgeDrawable} if none exists. Initializes (if needed) and +// * returns the associated instance of {@link BadgeDrawable}. +// * +// * @return an instance of BadgeDrawable associated with {@code Tab}. +// */ +// @NonNull +// private BadgeDrawable getOrCreateBadge() { +// // Creates a new instance if one is not already initialized for this TabView. +// if (badgeDrawable == null) { +// badgeDrawable = BadgeDrawable.create(getContext()); +// } +// tryUpdateBadgeAnchor(); +// if (badgeDrawable == null) { +// throw new IllegalStateException("Unable to create badge"); +// } +// return badgeDrawable; +// } +// +// @Nullable +// private BadgeDrawable getBadge() { +// return badgeDrawable; +// } +// +// private void removeBadge() { +// if (badgeAnchorView != null) { +// tryRemoveBadgeFromAnchor(); +// } +// badgeDrawable = null; +// } +// +// private void addOnLayoutChangeListener(@Nullable final View view) { +// if (view == null) { +// return; +// } +// view.addOnLayoutChangeListener( +// new OnLayoutChangeListener() { +// @Override +// public void onLayoutChange( +// View v, +// int left, +// int top, +// int right, +// int bottom, +// int oldLeft, +// int oldTop, +// int oldRight, +// int oldBottom) { +// if (view.getVisibility() == VISIBLE) { +// tryUpdateBadgeDrawableBounds(view); +// } +// } +// }); +// } +// +// private void tryUpdateBadgeAnchor() { +// if (!hasBadgeDrawable()) { +// return; +// } +// if (customView != null) { +// // TODO(b/123406505): Support badging on custom tab views. +// tryRemoveBadgeFromAnchor(); +// } else { +// if (iconView != null && tab != null && tab.getIcon() != null) { +// if (badgeAnchorView != iconView) { +// tryRemoveBadgeFromAnchor(); +// // Anchor badge to icon. +// tryAttachBadgeToAnchor(iconView); +// } else { +// tryUpdateBadgeDrawableBounds(iconView); +// } +// } else if (textView != null +// && tab != null +// && tab.getTabLabelVisibility() == TAB_LABEL_VISIBILITY_LABELED) { +// if (badgeAnchorView != textView) { +// tryRemoveBadgeFromAnchor(); +// // Anchor badge to label. +// tryAttachBadgeToAnchor(textView); +// } else { +// tryUpdateBadgeDrawableBounds(textView); +// } +// } else { +// tryRemoveBadgeFromAnchor(); +// } +// } +// } +// +// private void tryAttachBadgeToAnchor(@Nullable View anchorView) { +// if (!hasBadgeDrawable()) { +// return; +// } +// if (anchorView != null) { +// clipViewToPaddingForBadge(false); +//// BadgeUtils.attachBadgeDrawable( +//// badgeDrawable, anchorView, getCustomParentForBadge(anchorView)); +// badgeAnchorView = anchorView; +// } +// } +// +// private void tryRemoveBadgeFromAnchor() { +// if (!hasBadgeDrawable()) { +// return; +// } +// clipViewToPaddingForBadge(true); +// if (badgeAnchorView != null) { +//// BadgeUtils.detachBadgeDrawable(badgeDrawable, badgeAnchorView); +// badgeAnchorView = null; +// } +// } +// +// private void clipViewToPaddingForBadge(boolean flag) { +// // Avoid clipping a badge if it's displayed. +// // Clip children / view to padding when no badge is displayed. +// setClipChildren(flag); +// setClipToPadding(flag); +// ViewGroup parent = (ViewGroup) getParent(); +// if (parent != null) { +// parent.setClipChildren(flag); +// parent.setClipToPadding(flag); +// } +// } + + final void updateOrientation() { + setOrientation(inlineLabel ? HORIZONTAL : VERTICAL); + if (customTextView != null || customIconView != null) { + updateTextAndIcon(customTextView, customIconView); + } else { + updateTextAndIcon(textView, iconView); + } + } + + private void updateTextAndIcon( + @Nullable final TextView textView, @Nullable final ImageView iconView) { + final Drawable icon = + (tab != null && tab.getIcon() != null) + ? tab.getIcon().mutate() + : null; + if (icon != null) { + icon.setTintList(tabIconTint); + if (tabIconTintMode != null) { + icon.setTintMode(tabIconTintMode); + } + } + + final CharSequence text = tab != null ? tab.getText() : null; + + if (iconView != null) { + if (icon != null) { + iconView.setImageDrawable(icon); + iconView.setVisibility(VISIBLE); + setVisibility(VISIBLE); + } else { + iconView.setVisibility(GONE); + iconView.setImageDrawable(null); + } + } + + final boolean hasText = !TextUtils.isEmpty(text); + if (textView != null) { + if (hasText) { + textView.setText(text); + if (tab.labelVisibilityMode == TAB_LABEL_VISIBILITY_LABELED) { + textView.setVisibility(VISIBLE); + } else { + textView.setVisibility(GONE); + } + setVisibility(VISIBLE); + } else { + textView.setVisibility(GONE); + textView.setText(null); + } + } + + if (iconView != null) { + MarginLayoutParams lp = ((MarginLayoutParams) iconView.getLayoutParams()); + int iconMargin = 0; + if (hasText && iconView.getVisibility() == VISIBLE) { + // If we're showing both text and icon, add some margin bottom to the icon + iconMargin = (int) V.dp(DEFAULT_GAP_TEXT_ICON); + } + if (inlineLabel) { + if (iconMargin != lp.getMarginEnd()) { + lp.setMarginEnd(iconMargin); + lp.bottomMargin = 0; + // Calls resolveLayoutParams(), necessary for layout direction + iconView.setLayoutParams(lp); + iconView.requestLayout(); + } + } else { + if (iconMargin != lp.bottomMargin) { + lp.bottomMargin = iconMargin; + lp.setMarginEnd(0); + // Calls resolveLayoutParams(), necessary for layout direction + iconView.setLayoutParams(lp); + iconView.requestLayout(); + } + } + } + + final CharSequence contentDesc = tab != null ? tab.contentDesc : null; + // Avoid calling tooltip for L and M devices because long pressing twuice may freeze devices. + if (VERSION.SDK_INT >=26) { + setTooltipText(hasText ? text : contentDesc); + } + } +// +// private void tryUpdateBadgeDrawableBounds(@NonNull View anchor) { +// // Check that this view is the badge's current anchor view. +// if (hasBadgeDrawable() && anchor == badgeAnchorView) { +// BadgeUtils.setBadgeDrawableBounds(badgeDrawable, anchor, getCustomParentForBadge(anchor)); +// } +// } +// +// private boolean hasBadgeDrawable() { +// return badgeDrawable != null; +// } +// +// @Nullable +// private FrameLayout getCustomParentForBadge(@NonNull View anchor) { +// if (anchor != iconView && anchor != textView) { +// return null; +// } +// return BadgeUtils.USE_COMPAT_PARENT ? ((FrameLayout) anchor.getParent()) : null; +// } + + /** + * Calculates the width of the TabView's content. + * + * @return Width of the tab label, if present, or the width of the tab icon, if present. If tabs + * is in inline mode, returns the sum of both the icon and tab label widths. + */ + int getContentWidth() { + boolean initialized = false; + int left = 0; + int right = 0; + + for (View view : new View[] {textView, iconView, customView}) { + if (view != null && view.getVisibility() == View.VISIBLE) { + left = initialized ? Math.min(left, view.getLeft()) : view.getLeft(); + right = initialized ? Math.max(right, view.getRight()) : view.getRight(); + initialized = true; + } + } + + return right - left; + } + + /** + * Calculates the height of the TabView's content. + * + * @return Height of the tab label, if present, or the height of the tab icon, if present. If + * the tab contains both a label and icon, the combined will be returned. + */ + int getContentHeight() { + boolean initialized = false; + int top = 0; + int bottom = 0; + + for (View view : new View[] {textView, iconView, customView}) { + if (view != null && view.getVisibility() == View.VISIBLE) { + top = initialized ? Math.min(top, view.getTop()) : view.getTop(); + bottom = initialized ? Math.max(bottom, view.getBottom()) : view.getBottom(); + initialized = true; + } + } + + return bottom - top; + } + + @Nullable + public Tab getTab() { + return tab; + } + + /** Approximates a given lines width with the new provided text size. */ + private float approximateLineWidth(@NonNull Layout layout, int line, float textSize) { + return layout.getLineWidth(line) * (textSize / layout.getPaint().getTextSize()); + } + } + + class SlidingTabIndicator extends LinearLayout { + ValueAnimator indicatorAnimator; + int selectedPosition = -1; + // selectionOffset is only used when a tab is being slid due to a viewpager swipe. + // selectionOffset is always the offset to the right of selectedPosition. + float selectionOffset; + + private int layoutDirection = -1; + + SlidingTabIndicator(Context context) { + super(context); + setWillNotDraw(false); + } + + void setSelectedIndicatorHeight(int height) { + Rect bounds = tabSelectedIndicator.getBounds(); + tabSelectedIndicator.setBounds(bounds.left, 0, bounds.right, height); + this.requestLayout(); + } + + boolean childrenNeedLayout() { + for (int i = 0, z = getChildCount(); i < z; i++) { + final View child = getChildAt(i); + if (child.getWidth() <= 0) { + return true; + } + } + return false; + } + + /** + * Set the indicator position based on an offset between two adjacent tabs. + * + * @param position The position from which the offset should be calculated. + * @param positionOffset The offset to the right of position where the indicator should be + * drawn. This must be a value between 0.0 and 1.0. + */ + void setIndicatorPositionFromTabPosition(int position, float positionOffset) { + if (indicatorAnimator != null && indicatorAnimator.isRunning()) { + indicatorAnimator.cancel(); + } + + selectedPosition = position; + selectionOffset = positionOffset; + + final View selectedTitle = getChildAt(selectedPosition); + final View nextTitle = getChildAt(selectedPosition + 1); + + tweenIndicatorPosition(selectedTitle, nextTitle, selectionOffset); + } + + float getIndicatorPosition() { + return selectedPosition + selectionOffset; + } + + @Override + public void onRtlPropertiesChanged(int layoutDirection) { + super.onRtlPropertiesChanged(layoutDirection); + + // Workaround for a bug before Android M where LinearLayout did not re-layout itself when + // layout direction changed + if (VERSION.SDK_INT < VERSION_CODES.M) { + if (this.layoutDirection != layoutDirection) { + requestLayout(); + this.layoutDirection = layoutDirection; + } + } + } + + @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; + } + + // GRAVITY_CENTER will make all tabs the same width as the largest tab, and center them in the + // SlidingTabIndicator's width (with a "gutter" of padding on either side). If the Tabs do not + // fit in the SlidingTabIndicator, then fall back to GRAVITY_FILL behavior. + if ((tabGravity == GRAVITY_CENTER) || mode == MODE_AUTO) { + final int count = getChildCount(); + + // First we'll find the widest tab + int largestTabWidth = 0; + for (int i = 0, z = count; i < z; i++) { + View child = getChildAt(i); + if (child.getVisibility() == VISIBLE) { + 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 = (int) V.dp(FIXED_WRAP_GUTTER_MIN); + boolean remeasure = false; + + 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 LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams(); + if (lp.width != largestTabWidth || lp.weight != 0) { + lp.width = largestTabWidth; + lp.weight = 0; + remeasure = true; + } + } + } else { + // If the tabs will wrap to be larger than the width minus gutters, we need + // to switch to GRAVITY_FILL. + // TODO (b/129799806): This overrides the user TabGravity setting. + tabGravity = GRAVITY_FILL; + updateTabViews(false); + remeasure = true; + } + + if (remeasure) { + // 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 (indicatorAnimator != null && indicatorAnimator.isRunning()) { + // It's possible that the tabs' layout is modified while the indicator is animating (ex. a + // new tab is added, or a tab is removed in onTabSelected). This would change the target end + // position of the indicator, since the tab widths are different. We need to modify the + // animation's updateListener to pick up the new target positions. + updateOrRecreateIndicatorAnimation( + /* recreateAnimation= */ false, selectedPosition, /* duration= */ -1); + } else { + // If we've been laid out, update the indicator position + jumpIndicatorToSelectedPosition(); + } + } + + /** Immediately update the indicator position to the currently selected position. */ + private void jumpIndicatorToSelectedPosition() { + final View currentView = getChildAt(selectedPosition); + tabIndicatorInterpolator.setIndicatorBoundsForTab( + TabLayout.this, currentView, tabSelectedIndicator); + } + + /** + * Update the position of the indicator by tweening between the currently selected tab and the + * destination tab. + * + *

This method is called for each frame when either animating the indicator between + * destinations or driving an animation through gesture, such as with a viewpager. + * + * @param startTitle The tab which should be selected (as marked by the indicator), when + * fraction is 0.0. + * @param endTitle The tab which should be selected (as marked by the indicator), when fraction + * is 1.0. + * @param fraction A value between 0.0 and 1.0 that indicates how far between currentTitle and + * endTitle the indicator should be drawn. e.g. If a viewpager attached to this TabLayout is + * currently half way slid between page 0 and page 1, fraction will be 0.5. + */ + private void tweenIndicatorPosition(View startTitle, View endTitle, float fraction) { + boolean hasVisibleTitle = startTitle != null && startTitle.getWidth() > 0; + if (hasVisibleTitle) { + tabIndicatorInterpolator.setIndicatorBoundsForOffset( + TabLayout.this, startTitle, endTitle, fraction, tabSelectedIndicator); + } else { + // Hide the indicator by setting the drawable's width to 0 and off screen. + tabSelectedIndicator.setBounds( + -1, tabSelectedIndicator.getBounds().top, -1, tabSelectedIndicator.getBounds().bottom); + } + + postInvalidateOnAnimation(); + } + + /** + * Animate the position of the indicator from its current position to a new position. + * + *

This is typically used when a tab destination is tapped. If the indicator should be moved + * as a result of a gesture, see {@link #setIndicatorPositionFromTabPosition(int, float)}. + * + * @param position The new position to animate the indicator to. + * @param duration The duration over which the animation should take place. + */ + void animateIndicatorToPosition(final int position, int duration) { + if (indicatorAnimator != null && indicatorAnimator.isRunning()) { + indicatorAnimator.cancel(); + } + + updateOrRecreateIndicatorAnimation(/* recreateAnimation= */ true, position, duration); + } + + /** + * Animate the position of the indicator from its current position to a new position. + * + * @param recreateAnimation Whether a currently running animator should be re-targeted to move + * the indicator to it's new position. + * @param position The new position to animate the indicator to. + * @param duration The duration over which the animation should take place. + */ + private void updateOrRecreateIndicatorAnimation( + boolean recreateAnimation, final int position, int duration) { + final View currentView = getChildAt(selectedPosition); + final View targetView = getChildAt(position); + if (targetView == null) { + // If we don't have a view, just update the position now and return + jumpIndicatorToSelectedPosition(); + return; + } + + // Create the update listener with the new target indicator positions. If we're not recreating + // then animationStartLeft/Right will be the same as when the previous animator was created. + ValueAnimator.AnimatorUpdateListener updateListener = + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(@NonNull ValueAnimator valueAnimator) { + tweenIndicatorPosition(currentView, targetView, valueAnimator.getAnimatedFraction()); + } + }; + + if (recreateAnimation) { + // Create & start a new indicatorAnimator. + ValueAnimator animator = indicatorAnimator = new ValueAnimator(); + animator.setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR); + animator.setDuration(duration); + animator.setFloatValues(0F, 1F); + animator.addUpdateListener(updateListener); + animator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + selectedPosition = position; + } + + @Override + public void onAnimationEnd(Animator animator) { + selectedPosition = position; + } + }); + animator.start(); + } else { + // Reuse the existing animator. Updating the listener only modifies the target positions. + indicatorAnimator.removeAllUpdateListeners(); + indicatorAnimator.addUpdateListener(updateListener); + } + } + + @Override + public void draw(@NonNull Canvas canvas) { + int indicatorHeight = tabSelectedIndicator.getBounds().height(); + if (indicatorHeight < 0) { + indicatorHeight = tabSelectedIndicator.getIntrinsicHeight(); + } + + int indicatorTop = 0; + int indicatorBottom = 0; + + switch (tabIndicatorGravity) { + case INDICATOR_GRAVITY_BOTTOM: + indicatorTop = getHeight() - indicatorHeight; + indicatorBottom = getHeight(); + break; + case INDICATOR_GRAVITY_CENTER: + indicatorTop = (getHeight() - indicatorHeight) / 2; + indicatorBottom = (getHeight() + indicatorHeight) / 2; + break; + case INDICATOR_GRAVITY_TOP: + indicatorTop = 0; + indicatorBottom = indicatorHeight; + break; + case INDICATOR_GRAVITY_STRETCH: + indicatorTop = 0; + indicatorBottom = getHeight(); + break; + default: + break; + } + + // Ensure the drawable actually has a width and is worth drawing + if (tabSelectedIndicator.getBounds().width() > 0) { + // Use the left and right bounds of the drawable, as set by the indicator interpolator. + // Update the top and bottom to respect the indicator gravity property. + Rect indicatorBounds = tabSelectedIndicator.getBounds(); + tabSelectedIndicator.setBounds( + indicatorBounds.left, indicatorTop, indicatorBounds.right, indicatorBottom); + Drawable indicator = tabSelectedIndicator; + + if (tabSelectedIndicatorColor != Color.TRANSPARENT) { + // If a tint color has been specified using TabLayout's setSelectedTabIndicatorColor, wrap + // the drawable and tint it as specified. +// indicator = DrawableCompat.wrap(indicator); + indicator.setTint(tabSelectedIndicatorColor); + } else { + // Remove existing tint if setSelectedTabIndicatorColor to Color.Transparent. + indicator.setTintList(null); + } + + indicator.draw(canvas); + } + + // Draw the tab item contents (icon and label) on top of the background + indicator layers + super.draw(canvas); + } + } + + @NonNull + 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); + } + + @Dimension(unit = Dimension.DP) + private int getDefaultHeight() { + boolean hasIconAndText = false; + for (int i = 0, count = tabs.size(); i < count; i++) { + Tab tab = tabs.get(i); + if (tab != null && tab.getIcon() != null && !TextUtils.isEmpty(tab.getText())) { + hasIconAndText = true; + break; + } + } + return (hasIconAndText && !inlineLabel) ? DEFAULT_HEIGHT_WITH_TEXT_ICON : DEFAULT_HEIGHT; + } + + private int getTabMinWidth() { + if (requestedTabMinWidth != INVALID_WIDTH) { + // If we have been given a min width, use it + return requestedTabMinWidth; + } + // Else, we'll use the default value + return (mode == MODE_SCROLLABLE || mode == MODE_AUTO) ? scrollableTabMinWidth : 0; + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + // We don't care about the layout params of any views added to us, since we don't actually + // add them. The only view we add is the SlidingTabStrip, which is done manually. + // We return the default layout params so that we don't blow up if we're given a TabItem + // without android:layout_* values. + return generateDefaultLayoutParams(); + } + + int getTabMaxWidth() { + return tabMaxWidth; + } + + /** + * A {@link ViewPager.OnPageChangeListener} class which contains the necessary calls back to the + * provided {@link TabLayout} so that the tab position is kept in sync. + * + *

This class stores the provided TabLayout 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 TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener { + @NonNull private final WeakReference tabLayoutRef; + private int previousScrollState; + private int scrollState; + + public TabLayoutOnPageChangeListener(TabLayout tabLayout) { + tabLayoutRef = new WeakReference<>(tabLayout); + } + + @Override + public void onPageScrollStateChanged(final int state) { + previousScrollState = scrollState; + scrollState = state; + } + + @Override + public void onPageScrolled( + final int position, final float positionOffset, final int positionOffsetPixels) { + final TabLayout tabLayout = tabLayoutRef.get(); + if (tabLayout != null) { + // Only update the text selection if we're not settling, or we are settling after + // being dragged + final boolean updateText = + scrollState != SCROLL_STATE_SETTLING || previousScrollState == SCROLL_STATE_DRAGGING; + // Update the indicator if we're not settling after being idle. This is caused + // from a setCurrentItem() call and will be handled by an animation from + // onPageSelected() instead. + final boolean updateIndicator = + !(scrollState == SCROLL_STATE_SETTLING && previousScrollState == SCROLL_STATE_IDLE); + tabLayout.setScrollPosition(position, positionOffset, updateText, updateIndicator); + } + } + + @Override + public void onPageSelected(final int position) { + final TabLayout tabLayout = tabLayoutRef.get(); + if (tabLayout != null + && tabLayout.getSelectedTabPosition() != position + && position < tabLayout.getTabCount()) { + // Select the tab, only updating the indicator if we're not being dragged/settled + // (since onPageScrolled will handle that). + final boolean updateIndicator = + scrollState == SCROLL_STATE_IDLE + || (scrollState == SCROLL_STATE_SETTLING + && previousScrollState == SCROLL_STATE_IDLE); + tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator); + } + } + + void reset() { + previousScrollState = scrollState = SCROLL_STATE_IDLE; + } + } + + /** + * A {@link 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 OnTabSelectedListener { + private final ViewPager viewPager; + + public ViewPagerOnTabSelectedListener(ViewPager viewPager) { + this.viewPager = viewPager; + } + + @Override + public void onTabSelected(@NonNull Tab tab) { + viewPager.setCurrentItem(tab.getPosition()); + } + + @Override + public void onTabUnselected(Tab tab) { + // No-op + } + + @Override + public void onTabReselected(Tab tab) { + // No-op + } + } + + private class PagerAdapterObserver extends DataSetObserver { + PagerAdapterObserver() {} + + @Override + public void onChanged() { + populateFromPagerAdapter(); + } + + @Override + public void onInvalidated() { + populateFromPagerAdapter(); + } + } + + private class AdapterChangeListener implements ViewPager.OnAdapterChangeListener { + private boolean autoRefresh; + + AdapterChangeListener() {} + + @Override + public void onAdapterChanged( + @NonNull ViewPager viewPager, + @Nullable PagerAdapter oldAdapter, + @Nullable PagerAdapter newAdapter) { + if (TabLayout.this.viewPager == viewPager) { + setPagerAdapter(newAdapter, autoRefresh); + } + } + + void setAutoRefresh(boolean autoRefresh) { + this.autoRefresh = autoRefresh; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayoutMediator.java b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayoutMediator.java new file mode 100644 index 000000000..db2f1fe16 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/tabs/TabLayoutMediator.java @@ -0,0 +1,315 @@ +/* + * Copyright 2018 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 org.joinmastodon.android.ui.tabs; + +import static androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_DRAGGING; +import static androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_IDLE; +import static androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_SETTLING; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager2.widget.ViewPager2; +import java.lang.ref.WeakReference; + +/** + * A mediator to link a TabLayout with a ViewPager2. The mediator will synchronize the ViewPager2's + * position with the selected tab when a tab is selected, and the TabLayout's scroll position when + * the user drags the ViewPager2. TabLayoutMediator will listen to ViewPager2's OnPageChangeCallback + * to adjust tab when ViewPager2 moves. TabLayoutMediator listens to TabLayout's + * OnTabSelectedListener to adjust VP2 when tab moves. TabLayoutMediator listens to RecyclerView's + * AdapterDataObserver to recreate tab content when dataset changes. + * + *

Establish the link by creating an instance of this class, make sure the ViewPager2 has an + * adapter and then call {@link #attach()} on it. Instantiating a TabLayoutMediator will only create + * the mediator object, {@link #attach()} will link the TabLayout and the ViewPager2 together. When + * creating an instance of this class, you must supply an implementation of {@link + * TabConfigurationStrategy} in which you set the text of the tab, and/or perform any styling of the + * tabs that you require. Changing ViewPager2's adapter will require a {@link #detach()} followed by + * {@link #attach()} call. Changing the ViewPager2 or TabLayout will require a new instantiation of + * TabLayoutMediator. + */ +public final class TabLayoutMediator { + @NonNull private final TabLayout tabLayout; + @NonNull private final ViewPager2 viewPager; + private final boolean autoRefresh; + private final boolean smoothScroll; + private final TabConfigurationStrategy tabConfigurationStrategy; + @Nullable private RecyclerView.Adapter adapter; + private boolean attached; + + @Nullable private TabLayoutOnPageChangeCallback onPageChangeCallback; + @Nullable private TabLayout.OnTabSelectedListener onTabSelectedListener; + @Nullable private RecyclerView.AdapterDataObserver pagerAdapterObserver; + + /** + * A callback interface that must be implemented to set the text and styling of newly created + * tabs. + */ + public interface TabConfigurationStrategy { + /** + * Called to configure the tab for the page at the specified position. Typically calls {@link + * TabLayout.Tab#setText(CharSequence)}, but any form of styling can be applied. + * + * @param tab The Tab which should be configured to represent the title of the item at the given + * position in the data set. + * @param position The position of the item within the adapter's data set. + */ + void onConfigureTab(@NonNull TabLayout.Tab tab, int position); + } + + public TabLayoutMediator( + @NonNull TabLayout tabLayout, + @NonNull ViewPager2 viewPager, + @NonNull TabConfigurationStrategy tabConfigurationStrategy) { + this(tabLayout, viewPager, /* autoRefresh= */ true, tabConfigurationStrategy); + } + + public TabLayoutMediator( + @NonNull TabLayout tabLayout, + @NonNull ViewPager2 viewPager, + boolean autoRefresh, + @NonNull TabConfigurationStrategy tabConfigurationStrategy) { + this(tabLayout, viewPager, autoRefresh, /* smoothScroll= */ true, tabConfigurationStrategy); + } + + public TabLayoutMediator( + @NonNull TabLayout tabLayout, + @NonNull ViewPager2 viewPager, + boolean autoRefresh, + boolean smoothScroll, + @NonNull TabConfigurationStrategy tabConfigurationStrategy) { + this.tabLayout = tabLayout; + this.viewPager = viewPager; + this.autoRefresh = autoRefresh; + this.smoothScroll = smoothScroll; + this.tabConfigurationStrategy = tabConfigurationStrategy; + } + + /** + * Link the TabLayout and the ViewPager2 together. Must be called after ViewPager2 has an adapter + * set. To be called on a new instance of TabLayoutMediator or if the ViewPager2's adapter + * changes. + * + * @throws IllegalStateException If the mediator is already attached, or the ViewPager2 has no + * adapter. + */ + public void attach() { + if (attached) { + throw new IllegalStateException("TabLayoutMediator is already attached"); + } + adapter = viewPager.getAdapter(); + if (adapter == null) { + throw new IllegalStateException( + "TabLayoutMediator attached before ViewPager2 has an " + "adapter"); + } + attached = true; + + // Add our custom OnPageChangeCallback to the ViewPager + onPageChangeCallback = new TabLayoutOnPageChangeCallback(tabLayout); + viewPager.registerOnPageChangeCallback(onPageChangeCallback); + + // Now we'll add a tab selected listener to set ViewPager's current item + onTabSelectedListener = new ViewPagerOnTabSelectedListener(viewPager, smoothScroll); + tabLayout.addOnTabSelectedListener(onTabSelectedListener); + + // Now we'll populate ourselves from the pager adapter, adding an observer if + // autoRefresh is enabled + if (autoRefresh) { + // Register our observer on the new adapter + pagerAdapterObserver = new PagerAdapterObserver(); + adapter.registerAdapterDataObserver(pagerAdapterObserver); + } + + populateTabsFromPagerAdapter(); + + // Now update the scroll position to match the ViewPager's current item + tabLayout.setScrollPosition(viewPager.getCurrentItem(), 0f, true); + } + + /** + * Unlink the TabLayout and the ViewPager. To be called on a stale TabLayoutMediator if a new one + * is instantiated, to prevent holding on to a view that should be garbage collected. Also to be + * called before {@link #attach()} when a ViewPager2's adapter is changed. + */ + public void detach() { + if (autoRefresh && adapter != null) { + adapter.unregisterAdapterDataObserver(pagerAdapterObserver); + pagerAdapterObserver = null; + } + tabLayout.removeOnTabSelectedListener(onTabSelectedListener); + viewPager.unregisterOnPageChangeCallback(onPageChangeCallback); + onTabSelectedListener = null; + onPageChangeCallback = null; + adapter = null; + attached = false; + } + + /** + * Returns whether the {@link TabLayout} and the {@link ViewPager2} are linked together. + */ + public boolean isAttached() { + return attached; + } + + @SuppressWarnings("WeakerAccess") + void populateTabsFromPagerAdapter() { + tabLayout.removeAllTabs(); + + if (adapter != null) { + int adapterCount = adapter.getItemCount(); + for (int i = 0; i < adapterCount; i++) { + TabLayout.Tab tab = tabLayout.newTab(); + tabConfigurationStrategy.onConfigureTab(tab, i); + tabLayout.addTab(tab, false); + } + // Make sure we reflect the currently set ViewPager item + if (adapterCount > 0) { + int lastItem = tabLayout.getTabCount() - 1; + int currItem = Math.min(viewPager.getCurrentItem(), lastItem); + if (currItem != tabLayout.getSelectedTabPosition()) { + tabLayout.selectTab(tabLayout.getTabAt(currItem)); + } + } + } + } + + /** + * A {@link ViewPager2.OnPageChangeCallback} class which contains the necessary calls back to the + * provided {@link TabLayout} so that the tab position is kept in sync. + * + *

This class stores the provided TabLayout weakly, meaning that you can use {@link + * ViewPager2#registerOnPageChangeCallback(ViewPager2.OnPageChangeCallback)} without removing the + * callback and not cause a leak. + */ + private static class TabLayoutOnPageChangeCallback extends ViewPager2.OnPageChangeCallback { + @NonNull private final WeakReference tabLayoutRef; + private int previousScrollState; + private int scrollState; + + TabLayoutOnPageChangeCallback(TabLayout tabLayout) { + tabLayoutRef = new WeakReference<>(tabLayout); + reset(); + } + + @Override + public void onPageScrollStateChanged(final int state) { + previousScrollState = scrollState; + scrollState = state; + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + TabLayout tabLayout = tabLayoutRef.get(); + if (tabLayout != null) { + // Only update the text selection if we're not settling, or we are settling after + // being dragged + boolean updateText = + scrollState != SCROLL_STATE_SETTLING || previousScrollState == SCROLL_STATE_DRAGGING; + // Update the indicator if we're not settling after being idle. This is caused + // from a setCurrentItem() call and will be handled by an animation from + // onPageSelected() instead. + boolean updateIndicator = + !(scrollState == SCROLL_STATE_SETTLING && previousScrollState == SCROLL_STATE_IDLE); + tabLayout.setScrollPosition(position, positionOffset, updateText, updateIndicator); + } + } + + @Override + public void onPageSelected(final int position) { + TabLayout tabLayout = tabLayoutRef.get(); + if (tabLayout != null + && tabLayout.getSelectedTabPosition() != position + && position < tabLayout.getTabCount()) { + // Select the tab, only updating the indicator if we're not being dragged/settled + // (since onPageScrolled will handle that). + boolean updateIndicator = + scrollState == SCROLL_STATE_IDLE + || (scrollState == SCROLL_STATE_SETTLING + && previousScrollState == SCROLL_STATE_IDLE); + tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator); + } + } + + void reset() { + previousScrollState = scrollState = SCROLL_STATE_IDLE; + } + } + + /** + * A {@link TabLayout.OnTabSelectedListener} class which contains the necessary calls back to the + * provided {@link ViewPager2} so that the tab position is kept in sync. + */ + private static class ViewPagerOnTabSelectedListener implements TabLayout.OnTabSelectedListener { + private final ViewPager2 viewPager; + private final boolean smoothScroll; + + ViewPagerOnTabSelectedListener(ViewPager2 viewPager, boolean smoothScroll) { + this.viewPager = viewPager; + this.smoothScroll = smoothScroll; + } + + @Override + public void onTabSelected(@NonNull TabLayout.Tab tab) { + viewPager.setCurrentItem(tab.getPosition(), smoothScroll); + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) { + // No-op + } + + @Override + public void onTabReselected(TabLayout.Tab tab) { + // No-op + } + } + + private class PagerAdapterObserver extends RecyclerView.AdapterDataObserver { + PagerAdapterObserver() {} + + @Override + public void onChanged() { + populateTabsFromPagerAdapter(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount) { + populateTabsFromPagerAdapter(); + } + + @Override + public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) { + populateTabsFromPagerAdapter(); + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + populateTabsFromPagerAdapter(); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + populateTabsFromPagerAdapter(); + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + populateTabsFromPagerAdapter(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index f8e6c414d..e075b06c7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.ui.utils; +import android.annotation.SuppressLint; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.drawable.Drawable; @@ -43,6 +44,16 @@ public class UiUtils{ } } + @SuppressLint("DefaultLocale") + public static String abbreviateNumber(int n){ + if(n<1000) + return String.format("%,d", n); + else if(n<1_000_000) + return String.format("%,.1fK", n/1000f); + else + return String.format("%,.1fM", n/1_000_000f); + } + /** * Android 6.0 has a bug where start and end compound drawables don't get tinted. * This works around it by setting the tint colors directly to the drawables. @@ -64,4 +75,9 @@ public class UiUtils{ public static void runOnUiThread(Runnable runnable){ mainHandler.post(runnable); } + + /** Linear interpolation between {@code startValue} and {@code endValue} by {@code fraction}. */ + public static int lerp(int startValue, int endValue, float fraction) { + return startValue + Math.round(fraction * (endValue - startValue)); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/CoverImageView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CoverImageView.java new file mode 100644 index 000000000..7e5cead7d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CoverImageView.java @@ -0,0 +1,37 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.widget.ImageView; + +import androidx.annotation.Nullable; + +public class CoverImageView extends ImageView{ + private float imageTranslationY, imageScale=1f; + + public CoverImageView(Context context){ + super(context); + } + + public CoverImageView(Context context, @Nullable AttributeSet attrs){ + super(context, attrs); + } + + public CoverImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr){ + super(context, attrs, defStyleAttr); + } + + @Override + protected void onDraw(Canvas canvas){ + canvas.save(); + canvas.translate(0, imageTranslationY); + super.onDraw(canvas); + canvas.restore(); + } + + public void setTransform(float transY, float scale){ + imageTranslationY=transY; + imageScale=scale; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/CustomScrollView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CustomScrollView.java new file mode 100644 index 000000000..4f41332dc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CustomScrollView.java @@ -0,0 +1,2030 @@ +/* + * Copyright (C) 2006 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 org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.StrictMode; +import android.util.AttributeSet; +import android.util.Log; +import android.view.FocusFinder; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewDebug; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.animation.AnimationUtils; +import android.widget.EdgeEffect; +import android.widget.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.OverScroller; +import android.widget.ScrollView; + +import java.util.List; + +import androidx.annotation.ColorInt; +import androidx.annotation.InspectableProperty; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +/** + * Copied from AOSP to add nested scrolling flings. + * + * + * A view group that allows the view hierarchy placed within it to be scrolled. + * Scroll view may have only one direct child placed within it. + * To add multiple views within the scroll view, make + * the direct child you add a view group, for example {@link LinearLayout}, and + * place additional views within that LinearLayout. + * + *

Scroll view supports vertical scrolling only. For horizontal scrolling, + * use {@link HorizontalScrollView} instead.

+ * + *

Never add a {@link android.support.v7.widget.RecyclerView} or {@link ListView} to + * a scroll view. Doing so results in poor user interface performance and a poor user + * experience.

+ * + *

+ * For vertical scrolling, consider {@link android.support.v4.widget.NestedScrollView} + * instead of scroll view which offers greater user interface flexibility and + * support for the material design scrolling patterns.

+ * + *

Material Design offers guidelines on how the appearance of + * several UI components, including app bars and + * banners, should respond to gestures.

+ * + * @attr ref android.R.styleable#ScrollView_fillViewport + */ +public class CustomScrollView extends FrameLayout{ + static final int ANIMATED_SCROLL_GAP = 250; + + static final float MAX_SCROLL_FACTOR = 0.5f; + + private static final String TAG = "ScrollView"; + + private long mLastScroll; + + private final Rect mTempRect = new Rect(); + private OverScroller mScroller; + /** + * Tracks the state of the top edge glow. + * + * Even though this field is practically final, we cannot make it final because there are apps + * setting it via reflection and they need to keep working until they target Q. + * @hide + */ + @NonNull + @VisibleForTesting + public EdgeEffect mEdgeGlowTop; + + /** + * Tracks the state of the bottom edge glow. + * + * Even though this field is practically final, we cannot make it final because there are apps + * setting it via reflection and they need to keep working until they target Q. + * @hide + */ + @NonNull + @VisibleForTesting + public EdgeEffect mEdgeGlowBottom; + + /** + * Position of the last motion event. + */ + private int mLastMotionY; + + /** + * True when the layout has changed but the traversal has not come through yet. + * Ideally the view hierarchy would keep track of this for us. + */ + private boolean mIsLayoutDirty = true; + + /** + * The child to give focus to in the event that a child has requested focus while the + * layout is dirty. This prevents the scroll from being wrong if the child has not been + * laid out before requesting focus. + */ + private View mChildToScrollTo = null; + + /** + * True if the user is currently dragging this ScrollView around. This is + * not the same as 'is being flinged', which can be checked by + * mScroller.isFinished() (flinging begins when the user lifts their finger). + */ + private boolean mIsBeingDragged = false; + + /** + * Determines speed during touch scrolling + */ + private VelocityTracker mVelocityTracker; + + /** + * When set to true, the scroll view measure its child to make it fill the currently + * visible area. + */ + @ViewDebug.ExportedProperty(category = "layout") + private boolean mFillViewport; + + /** + * Whether arrow scrolling is animated. + */ + private boolean mSmoothScrollingEnabled = true; + + private int mTouchSlop; + private int mMinimumVelocity; + private int mMaximumVelocity; + + private int mOverscrollDistance; + private int mOverflingDistance; + + private float mVerticalScrollFactor; + + /** + * ID of the active pointer. This is used to retain consistency during + * drags/flings if multiple pointers are used. + */ + private int mActivePointerId = INVALID_POINTER; + + /** + * Used during scrolling to retrieve the new offset within the window. + */ + private final int[] mScrollOffset = new int[2]; + private final int[] mScrollConsumed = new int[2]; + private int mNestedYOffset; + + /** + * Sentinel value for no current active pointer. + * Used by {@link #mActivePointerId}. + */ + private static final int INVALID_POINTER = -1; + + private SavedState mSavedState; + + private View nestedScrollingTarget; + + public CustomScrollView(Context context) { + this(context, null); + } + + public CustomScrollView(Context context, AttributeSet attrs) { + this(context, attrs, /*com.android.internal.R.attr.scrollViewStyle*/0); + } + + public CustomScrollView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public CustomScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + mEdgeGlowTop = new EdgeEffect(context/*, attrs*/); + mEdgeGlowBottom = new EdgeEffect(context/*, attrs*/); + initScrollView(); + +// final TypedArray a = context.obtainStyledAttributes( +// attrs, /*android.R.styleable.ScrollView*/null, defStyleAttr, defStyleRes); +// saveAttributeDataForStyleable(context, com.android.internal.R.styleable.ScrollView, +// attrs, a, defStyleAttr, defStyleRes); + +// setFillViewport(a.getBoolean(android.R.styleable.ScrollView_fillViewport, false)); + +// a.recycle(); + +// if (context.getResources().getConfiguration().uiMode == Configuration.UI_MODE_TYPE_WATCH) { +// setRevealOnFocusHint(false); +// } + } + + @Override + public boolean shouldDelayChildPressedState() { + return true; + } + + @Override + protected float getTopFadingEdgeStrength() { + if (getChildCount() == 0) { + return 0.0f; + } + + final int length = getVerticalFadingEdgeLength(); + if (getScrollY() < length) { + return getScrollY() / (float) length; + } + + return 1.0f; + } + + @Override + protected float getBottomFadingEdgeStrength() { + if (getChildCount() == 0) { + return 0.0f; + } + + final int length = getVerticalFadingEdgeLength(); + final int bottomEdge = getHeight() - getPaddingBottom(); + final int span = getChildAt(0).getBottom() - getScrollY() - bottomEdge; + if (span < length) { + return span / (float) length; + } + + return 1.0f; + } + + /** + * Sets the edge effect color for both top and bottom edge effects. + * + * @param color The color for the edge effects. + * @see #setTopEdgeEffectColor(int) + * @see #setBottomEdgeEffectColor(int) + * @see #getTopEdgeEffectColor() + * @see #getBottomEdgeEffectColor() + */ + public void setEdgeEffectColor(@ColorInt int color) { + setTopEdgeEffectColor(color); + setBottomEdgeEffectColor(color); + } + + /** + * Sets the bottom edge effect color. + * + * @param color The color for the bottom edge effect. + * @see #setTopEdgeEffectColor(int) + * @see #setEdgeEffectColor(int) + * @see #getTopEdgeEffectColor() + * @see #getBottomEdgeEffectColor() + */ + public void setBottomEdgeEffectColor(@ColorInt int color) { + mEdgeGlowBottom.setColor(color); + } + + /** + * Sets the top edge effect color. + * + * @param color The color for the top edge effect. + * @see #setBottomEdgeEffectColor(int) + * @see #setEdgeEffectColor(int) + * @see #getTopEdgeEffectColor() + * @see #getBottomEdgeEffectColor() + */ + public void setTopEdgeEffectColor(@ColorInt int color) { + mEdgeGlowTop.setColor(color); + } + + /** + * Returns the top edge effect color. + * + * @return The top edge effect color. + * @see #setEdgeEffectColor(int) + * @see #setTopEdgeEffectColor(int) + * @see #setBottomEdgeEffectColor(int) + * @see #getBottomEdgeEffectColor() + */ + @ColorInt + public int getTopEdgeEffectColor() { + return mEdgeGlowTop.getColor(); + } + + /** + * Returns the bottom edge effect color. + * + * @return The bottom edge effect color. + * @see #setEdgeEffectColor(int) + * @see #setTopEdgeEffectColor(int) + * @see #setBottomEdgeEffectColor(int) + * @see #getTopEdgeEffectColor() + */ + @ColorInt + public int getBottomEdgeEffectColor() { + return mEdgeGlowBottom.getColor(); + } + + /** + * @return The maximum amount this scroll view will scroll in response to + * an arrow event. + */ + public int getMaxScrollAmount() { + return (int) (MAX_SCROLL_FACTOR * (getBottom() - getTop())); + } + + private void initScrollView() { + mScroller = new OverScroller(getContext()); + setFocusable(true); + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + setWillNotDraw(false); + final ViewConfiguration configuration = ViewConfiguration.get(getContext()); + mTouchSlop = configuration.getScaledTouchSlop(); + mMinimumVelocity = configuration.getScaledMinimumFlingVelocity(); + mMaximumVelocity = configuration.getScaledMaximumFlingVelocity(); + mOverscrollDistance = configuration.getScaledOverscrollDistance(); + mOverflingDistance = configuration.getScaledOverflingDistance(); + mVerticalScrollFactor = Build.VERSION.SDK_INT>=26 ? configuration.getScaledVerticalScrollFactor() : 1; + } + + @Override + public void addView(View child) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child); + } + + @Override + public void addView(View child, int index) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, index); + } + + @Override + public void addView(View child, ViewGroup.LayoutParams params) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, params); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + if (getChildCount() > 0) { + throw new IllegalStateException("ScrollView can host only one direct child"); + } + + super.addView(child, index, params); + } + + /** + * @return Returns true this ScrollView can be scrolled + */ + private boolean canScroll() { + View child = getChildAt(0); + if (child != null) { + int childHeight = child.getHeight(); + return getHeight() < childHeight + getPaddingTop() + getPaddingBottom(); + } + return false; + } + + /** + * Indicates whether this ScrollView's content is stretched to fill the viewport. + * + * @return True if the content fills the viewport, false otherwise. + * + * @attr ref android.R.styleable#ScrollView_fillViewport + */ + @InspectableProperty + public boolean isFillViewport() { + return mFillViewport; + } + + /** + * Indicates this ScrollView whether it should stretch its content height to fill + * the viewport or not. + * + * @param fillViewport True to stretch the content's height to the viewport's + * boundaries, false otherwise. + * + * @attr ref android.R.styleable#ScrollView_fillViewport + */ + public void setFillViewport(boolean fillViewport) { + if (fillViewport != mFillViewport) { + mFillViewport = fillViewport; + requestLayout(); + } + } + + /** + * @return Whether arrow scrolling will animate its transition. + */ + public boolean isSmoothScrollingEnabled() { + return mSmoothScrollingEnabled; + } + + /** + * Set whether arrow scrolling will animate its transition. + * @param smoothScrollingEnabled whether arrow scrolling will animate its transition + */ + public void setSmoothScrollingEnabled(boolean smoothScrollingEnabled) { + mSmoothScrollingEnabled = smoothScrollingEnabled; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (!mFillViewport) { + return; + } + + final int heightMode = MeasureSpec.getMode(heightMeasureSpec); + if (heightMode == MeasureSpec.UNSPECIFIED) { + return; + } + + if (getChildCount() > 0) { + final View child = getChildAt(0); + final int widthPadding; + final int heightPadding; + final int targetSdkVersion = getContext().getApplicationInfo().targetSdkVersion; + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (targetSdkVersion >= VERSION_CODES.M) { + widthPadding = getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin; + heightPadding = getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin; + } else { + widthPadding = getPaddingLeft() + getPaddingRight(); + heightPadding = getPaddingTop() + getPaddingBottom(); + } + + final int desiredHeight = getMeasuredHeight() - heightPadding; + if (child.getMeasuredHeight() < desiredHeight) { + final int childWidthMeasureSpec = getChildMeasureSpec( + widthMeasureSpec, widthPadding, lp.width); + final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + desiredHeight, MeasureSpec.EXACTLY); + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + } + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Let the focused view and/or our descendants get the key first + return super.dispatchKeyEvent(event) || executeKeyEvent(event); + } + + /** + * You can call this function yourself to have the scroll view perform + * scrolling from a key event, just as if the event had been dispatched to + * it by the view hierarchy. + * + * @param event The key event to execute. + * @return Return true if the event was handled, else false. + */ + public boolean executeKeyEvent(KeyEvent event) { + mTempRect.setEmpty(); + + if (!canScroll()) { + if (isFocused() && event.getKeyCode() != KeyEvent.KEYCODE_BACK) { + View currentFocused = findFocus(); + if (currentFocused == this) currentFocused = null; + View nextFocused = FocusFinder.getInstance().findNextFocus(this, + currentFocused, View.FOCUS_DOWN); + return nextFocused != null + && nextFocused != this + && nextFocused.requestFocus(View.FOCUS_DOWN); + } + return false; + } + + boolean handled = false; + if (event.getAction() == KeyEvent.ACTION_DOWN) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_DPAD_UP: + if (!event.isAltPressed()) { + handled = arrowScroll(View.FOCUS_UP); + } else { + handled = fullScroll(View.FOCUS_UP); + } + break; + case KeyEvent.KEYCODE_DPAD_DOWN: + if (!event.isAltPressed()) { + handled = arrowScroll(View.FOCUS_DOWN); + } else { + handled = fullScroll(View.FOCUS_DOWN); + } + break; + case KeyEvent.KEYCODE_SPACE: + pageScroll(event.isShiftPressed() ? View.FOCUS_UP : View.FOCUS_DOWN); + break; + } + } + + return handled; + } + + private boolean inChild(int x, int y) { + if (getChildCount() > 0) { + final int scrollY = getScrollY(); + final View child = getChildAt(0); + return !(y < child.getTop() - scrollY + || y >= child.getBottom() - scrollY + || x < child.getLeft() + || x >= child.getRight()); + } + return false; + } + + private void initOrResetVelocityTracker() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } else { + mVelocityTracker.clear(); + } + } + + private void initVelocityTrackerIfNotExists() { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + } + + private void recycleVelocityTracker() { + if (mVelocityTracker != null) { + mVelocityTracker.recycle(); + mVelocityTracker = null; + } + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { + if (disallowIntercept) { + recycleVelocityTracker(); + } + super.requestDisallowInterceptTouchEvent(disallowIntercept); + } + + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + /* + * This method JUST determines whether we want to intercept the motion. + * If we return true, onMotionEvent will be called and we do the actual + * scrolling there. + */ + + /* + * Shortcut the most recurring case: the user is in the dragging + * state and they is moving their finger. We want to intercept this + * motion. + */ + final int action = ev.getAction(); + if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) { + return true; + } + + if (super.onInterceptTouchEvent(ev)) { + return true; + } + + /* + * Don't try to intercept touch if we can't scroll anyway. + */ + if (getScrollY() == 0 && !canScrollVertically(1)) { + return false; + } + + switch (action & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_MOVE: { + /* + * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check + * whether the user has moved far enough from their original down touch. + */ + + /* + * Locally do absolute value. mLastMotionY is set to the y value + * of the down event. + */ + final int activePointerId = mActivePointerId; + if (activePointerId == INVALID_POINTER) { + // If we don't have a valid id, the touch down wasn't on content. + break; + } + + final int pointerIndex = ev.findPointerIndex(activePointerId); + if (pointerIndex == -1) { + Log.e(TAG, "Invalid pointerId=" + activePointerId + + " in onInterceptTouchEvent"); + break; + } + + final int y = (int) ev.getY(pointerIndex); + final int yDiff = Math.abs(y - mLastMotionY); + if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) { + mIsBeingDragged = true; + mLastMotionY = y; + initVelocityTrackerIfNotExists(); + mVelocityTracker.addMovement(ev); + mNestedYOffset = 0; +// if (mScrollStrictSpan == null) { +// mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll"); +// } + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + break; + } + + case MotionEvent.ACTION_DOWN: { + final int y = (int) ev.getY(); + if (!inChild((int) ev.getX(), (int) y)) { + mIsBeingDragged = false; + recycleVelocityTracker(); + break; + } + + /* + * Remember location of down touch. + * ACTION_DOWN always refers to pointer index 0. + */ + mLastMotionY = y; + mActivePointerId = ev.getPointerId(0); + + initOrResetVelocityTracker(); + mVelocityTracker.addMovement(ev); + /* + * If being flinged and user touches the screen, initiate drag; + * otherwise don't. mScroller.isFinished should be false when + * being flinged. We need to call computeScrollOffset() first so that + * isFinished() is correct. + */ + mScroller.computeScrollOffset(); + mIsBeingDragged = !mScroller.isFinished() || !mEdgeGlowBottom.isFinished() + || !mEdgeGlowTop.isFinished(); + // Catch the edge effect if it is active. + if (Build.VERSION.SDK_INT>=31 && !mEdgeGlowTop.isFinished()) { + mEdgeGlowTop.onPullDistance(0f, ev.getX() / getWidth()); + } + if (Build.VERSION.SDK_INT>=31 && !mEdgeGlowBottom.isFinished()) { + mEdgeGlowBottom.onPullDistance(0f, 1f - ev.getX() / getWidth()); + } + if(mIsBeingDragged){ + mScroller.abortAnimation(); + mIsBeingDragged=false; + } +// if (mIsBeingDragged && mScrollStrictSpan == null) { +// mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll"); +// } + startNestedScroll(SCROLL_AXIS_VERTICAL); + break; + } + + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + /* Release the drag */ + mIsBeingDragged = false; + mActivePointerId = INVALID_POINTER; + recycleVelocityTracker(); + if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) { + postInvalidateOnAnimation(); + } + stopNestedScroll(); + break; + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + } + + /* + * The only time we want to intercept motion events is if we are in the + * drag mode. + */ + return mIsBeingDragged; + } + + private boolean shouldDisplayEdgeEffects() { + return getOverScrollMode() != OVER_SCROLL_NEVER; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + initVelocityTrackerIfNotExists(); + + MotionEvent vtev = MotionEvent.obtain(ev); + + final int actionMasked = ev.getActionMasked(); + + if (actionMasked == MotionEvent.ACTION_DOWN) { + mNestedYOffset = 0; + } + vtev.offsetLocation(0, mNestedYOffset); + + switch (actionMasked) { + case MotionEvent.ACTION_DOWN: { + if (getChildCount() == 0) { + return false; + } + if (!mScroller.isFinished() && nestedScrollingTarget==null) { + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + + /* + * If being flinged and user touches, stop the fling. isFinished + * will be false if being flinged. + */ + if (!mScroller.isFinished()) { + mScroller.abortAnimation(); +// if (mFlingStrictSpan != null) { +// mFlingStrictSpan.finish(); +// mFlingStrictSpan = null; +// } + } + + // Remember where the motion event started + mLastMotionY = (int) ev.getY(); + mActivePointerId = ev.getPointerId(0); + startNestedScroll(SCROLL_AXIS_VERTICAL); + break; + } + case MotionEvent.ACTION_MOVE: + final int activePointerIndex = ev.findPointerIndex(mActivePointerId); + if (activePointerIndex == -1) { + Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent"); + break; + } + + final int y = (int) ev.getY(activePointerIndex); + int deltaY = mLastMotionY - y; + if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) { + deltaY -= mScrollConsumed[1]; + vtev.offsetLocation(0, mScrollOffset[1]); + mNestedYOffset += mScrollOffset[1]; + } + if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) { + final ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + mIsBeingDragged = true; + if (deltaY > 0) { + deltaY -= mTouchSlop; + } else { + deltaY += mTouchSlop; + } + } + if (mIsBeingDragged) { + // Scroll to follow the motion event + mLastMotionY = y - mScrollOffset[1]; + + final int oldY = getScrollY(); + final int range = getScrollRange(); + final int overscrollMode = getOverScrollMode(); + boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS || + (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); + + final float displacement = ev.getX(activePointerIndex) / getWidth(); + if (canOverscroll) { + int consumed = 0; +// if (deltaY < 0 && mEdgeGlowBottom.getDistance() != 0f) { +// consumed = Math.round(getHeight() +// * mEdgeGlowBottom.onPullDistance((float) deltaY / getHeight(), +// 1 - displacement)); +// } else if (deltaY > 0 && mEdgeGlowTop.getDistance() != 0f) { +// consumed = Math.round(-getHeight() +// * mEdgeGlowTop.onPullDistance((float) -deltaY / getHeight(), +// displacement)); +// } + deltaY -= consumed; + } + + // Calling overScrollBy will call onOverScrolled, which + // calls onScrollChanged if applicable. + if (overScrollBy(0, deltaY, 0, getScrollY(), 0, range, 0, mOverscrollDistance, true) + && !hasNestedScrollingParent()) { + // Break our velocity if we hit a scroll barrier. + mVelocityTracker.clear(); + } + + final int scrolledDeltaY = getScrollY() - oldY; + final int unconsumedY = deltaY - scrolledDeltaY; + if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) { + mLastMotionY -= mScrollOffset[1]; + vtev.offsetLocation(0, mScrollOffset[1]); + mNestedYOffset += mScrollOffset[1]; + } else if (canOverscroll && deltaY != 0f) { + final int pulledToY = oldY + deltaY; + if (pulledToY < 0) { + mEdgeGlowTop.onPull((float) -deltaY / getHeight(), + displacement); + if (!mEdgeGlowBottom.isFinished()) { + mEdgeGlowBottom.onRelease(); + } + } else if (pulledToY > range) { + mEdgeGlowBottom.onPull((float) deltaY / getHeight(), + 1.f - displacement); + if (!mEdgeGlowTop.isFinished()) { + mEdgeGlowTop.onRelease(); + } + } + if (shouldDisplayEdgeEffects() + && (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) { + postInvalidateOnAnimation(); + } + } + } + break; + case MotionEvent.ACTION_UP: + if (mIsBeingDragged) { + final VelocityTracker velocityTracker = mVelocityTracker; + velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); + int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId); + + if ((Math.abs(initialVelocity) > mMinimumVelocity)) { + flingWithNestedDispatch(-initialVelocity); + } else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, + getScrollRange())) { + postInvalidateOnAnimation(); + } + + mActivePointerId = INVALID_POINTER; + endDrag(); + } + break; + case MotionEvent.ACTION_CANCEL: + if (mIsBeingDragged && getChildCount() > 0) { + if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) { + postInvalidateOnAnimation(); + } + mActivePointerId = INVALID_POINTER; + endDrag(); + } + break; + case MotionEvent.ACTION_POINTER_DOWN: { + final int index = ev.getActionIndex(); + mLastMotionY = (int) ev.getY(index); + mActivePointerId = ev.getPointerId(index); + break; + } + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId)); + break; + } + + if (mVelocityTracker != null) { + mVelocityTracker.addMovement(vtev); + } + vtev.recycle(); + return true; + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> + MotionEvent.ACTION_POINTER_INDEX_SHIFT; + final int pointerId = ev.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + // TODO: Make this decision more intelligent. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mLastMotionY = (int) ev.getY(newPointerIndex); + mActivePointerId = ev.getPointerId(newPointerIndex); + if (mVelocityTracker != null) { + mVelocityTracker.clear(); + } + } + } + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_SCROLL: + final float axisValue; + if (event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) { + axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + } else if (event.isFromSource(InputDevice.SOURCE_ROTARY_ENCODER)) { + axisValue = event.getAxisValue(MotionEvent.AXIS_SCROLL); + } else { + axisValue = 0; + } + + final int delta = Math.round(axisValue * mVerticalScrollFactor); + if (delta != 0) { + final int range = getScrollRange(); + int oldScrollY = getScrollY(); + int newScrollY = oldScrollY - delta; + if (newScrollY < 0) { + newScrollY = 0; + } else if (newScrollY > range) { + newScrollY = range; + } + if (newScrollY != oldScrollY) { + super.scrollTo(getScrollX(), newScrollY); + return true; + } + } + break; + } + + return super.onGenericMotionEvent(event); + } + + @Override + protected void onOverScrolled(int scrollX, int scrollY, + boolean clampedX, boolean clampedY) { + // Treat animating scrolls differently; see #computeScroll() for why. + if (!mScroller.isFinished()) { + final int oldX = getScrollX(); + final int oldY = getScrollY(); + setScrollX(scrollX); + setScrollY(scrollY); + //invalidateParentIfNeeded(); + onScrollChanged(getScrollX(), getScrollY(), oldX, oldY); + if (clampedY) { + mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange()); + } + } else { + super.scrollTo(scrollX, scrollY); + } + + awakenScrollBars(); + } + + /** @hide */ + @Override + public boolean performAccessibilityAction(int action, Bundle arguments) { + if (super.performAccessibilityAction(action, arguments)) { + return true; + } + if (!isEnabled()) { + return false; + } + switch (action) { + case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: + case android.R.id.accessibilityActionScrollDown: { + final int viewportHeight = getHeight() - getPaddingBottom() - getPaddingTop(); + final int targetScrollY = Math.min(getScrollY() + viewportHeight, getScrollRange()); + if (targetScrollY != getScrollY()) { + smoothScrollTo(0, targetScrollY); + return true; + } + } return false; + case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: + case android.R.id.accessibilityActionScrollUp: { + final int viewportHeight = getHeight() - getPaddingBottom() - getPaddingTop(); + final int targetScrollY = Math.max(getScrollY() - viewportHeight, 0); + if (targetScrollY != getScrollY()) { + smoothScrollTo(0, targetScrollY); + return true; + } + } return false; + } + return false; + } + + @Override + public CharSequence getAccessibilityClassName() { + return ScrollView.class.getName(); + } + + /** @hide */ + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + if (isEnabled()) { + final int scrollRange = getScrollRange(); + if (scrollRange > 0) { + info.setScrollable(true); + if (getScrollY() > 0) { + info.addAction( + AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_UP); + } + if (getScrollY() < scrollRange) { + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_DOWN); + } + } + } + } + + /** @hide */ + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + final boolean scrollable = getScrollRange() > 0; + event.setScrollable(scrollable); + event.setMaxScrollX(getScrollX()); + event.setMaxScrollY(getScrollRange()); + } + + private int getScrollRange() { + int scrollRange = 0; + if (getChildCount() > 0) { + View child = getChildAt(0); + scrollRange = Math.max(0, + child.getHeight() - (getHeight() - getPaddingBottom() - getPaddingTop())); + } + return scrollRange; + } + + /** + *

+ * Finds the next focusable component that fits in the specified bounds. + *

+ * + * @param topFocus look for a candidate is the one at the top of the bounds + * if topFocus is true, or at the bottom of the bounds if topFocus is + * false + * @param top the top offset of the bounds in which a focusable must be + * found + * @param bottom the bottom offset of the bounds in which a focusable must + * be found + * @return the next focusable component in the bounds or null if none can + * be found + */ + private View findFocusableViewInBounds(boolean topFocus, int top, int bottom) { + + List focusables = getFocusables(View.FOCUS_FORWARD); + View focusCandidate = null; + + /* + * A fully contained focusable is one where its top is below the bound's + * top, and its bottom is above the bound's bottom. A partially + * contained focusable is one where some part of it is within the + * bounds, but it also has some part that is not within bounds. A fully contained + * focusable is preferred to a partially contained focusable. + */ + boolean foundFullyContainedFocusable = false; + + int count = focusables.size(); + for (int i = 0; i < count; i++) { + View view = focusables.get(i); + int viewTop = view.getTop(); + int viewBottom = view.getBottom(); + + if (top < viewBottom && viewTop < bottom) { + /* + * the focusable is in the target area, it is a candidate for + * focusing + */ + + final boolean viewIsFullyContained = (top < viewTop) && + (viewBottom < bottom); + + if (focusCandidate == null) { + /* No candidate, take this one */ + focusCandidate = view; + foundFullyContainedFocusable = viewIsFullyContained; + } else { + final boolean viewIsCloserToBoundary = + (topFocus && viewTop < focusCandidate.getTop()) || + (!topFocus && viewBottom > focusCandidate + .getBottom()); + + if (foundFullyContainedFocusable) { + if (viewIsFullyContained && viewIsCloserToBoundary) { + /* + * We're dealing with only fully contained views, so + * it has to be closer to the boundary to beat our + * candidate + */ + focusCandidate = view; + } + } else { + if (viewIsFullyContained) { + /* Any fully contained view beats a partially contained view */ + focusCandidate = view; + foundFullyContainedFocusable = true; + } else if (viewIsCloserToBoundary) { + /* + * Partially contained view beats another partially + * contained view if it's closer + */ + focusCandidate = view; + } + } + } + } + } + + return focusCandidate; + } + + /** + *

Handles scrolling in response to a "page up/down" shortcut press. This + * method will scroll the view by one page up or down and give the focus + * to the topmost/bottommost component in the new visible area. If no + * component is a good candidate for focus, this scrollview reclaims the + * focus.

+ * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go one page up or + * {@link View#FOCUS_DOWN} to go one page down + * @return true if the key event is consumed by this method, false otherwise + */ + public boolean pageScroll(int direction) { + boolean down = direction == View.FOCUS_DOWN; + int height = getHeight(); + + if (down) { + mTempRect.top = getScrollY() + height; + int count = getChildCount(); + if (count > 0) { + View view = getChildAt(count - 1); + if (mTempRect.top + height > view.getBottom()) { + mTempRect.top = view.getBottom() - height; + } + } + } else { + mTempRect.top = getScrollY() - height; + if (mTempRect.top < 0) { + mTempRect.top = 0; + } + } + mTempRect.bottom = mTempRect.top + height; + + return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom); + } + + /** + *

Handles scrolling in response to a "home/end" shortcut press. This + * method will scroll the view to the top or bottom and give the focus + * to the topmost/bottommost component in the new visible area. If no + * component is a good candidate for focus, this scrollview reclaims the + * focus.

+ * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go the top of the view or + * {@link View#FOCUS_DOWN} to go the bottom + * @return true if the key event is consumed by this method, false otherwise + */ + public boolean fullScroll(int direction) { + boolean down = direction == View.FOCUS_DOWN; + int height = getHeight(); + + mTempRect.top = 0; + mTempRect.bottom = height; + + if (down) { + int count = getChildCount(); + if (count > 0) { + View view = getChildAt(count - 1); + mTempRect.bottom = view.getBottom() + getPaddingBottom(); + mTempRect.top = mTempRect.bottom - height; + } + } + + return scrollAndFocus(direction, mTempRect.top, mTempRect.bottom); + } + + /** + *

Scrolls the view to make the area defined by top and + * bottom visible. This method attempts to give the focus + * to a component visible in this area. If no component can be focused in + * the new visible area, the focus is reclaimed by this ScrollView.

+ * + * @param direction the scroll direction: {@link View#FOCUS_UP} + * to go upward, {@link View#FOCUS_DOWN} to downward + * @param top the top offset of the new area to be made visible + * @param bottom the bottom offset of the new area to be made visible + * @return true if the key event is consumed by this method, false otherwise + */ + private boolean scrollAndFocus(int direction, int top, int bottom) { + boolean handled = true; + + int height = getHeight(); + int containerTop = getScrollY(); + int containerBottom = containerTop + height; + boolean up = direction == View.FOCUS_UP; + + View newFocused = findFocusableViewInBounds(up, top, bottom); + if (newFocused == null) { + newFocused = this; + } + + if (top >= containerTop && bottom <= containerBottom) { + handled = false; + } else { + int delta = up ? (top - containerTop) : (bottom - containerBottom); + doScrollY(delta); + } + + if (newFocused != findFocus()) newFocused.requestFocus(direction); + + return handled; + } + + /** + * Handle scrolling in response to an up or down arrow click. + * + * @param direction The direction corresponding to the arrow key that was + * pressed + * @return True if we consumed the event, false otherwise + */ + public boolean arrowScroll(int direction) { + + View currentFocused = findFocus(); + if (currentFocused == this) currentFocused = null; + + View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction); + + final int maxJump = getMaxScrollAmount(); + + if (nextFocused != null && isWithinDeltaOfScreen(nextFocused, maxJump, getHeight())) { + nextFocused.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(nextFocused, mTempRect); + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + doScrollY(scrollDelta); + nextFocused.requestFocus(direction); + } else { + // no new focus + int scrollDelta = maxJump; + + if (direction == View.FOCUS_UP && getScrollY() < scrollDelta) { + scrollDelta = getScrollY(); + } else if (direction == View.FOCUS_DOWN) { + if (getChildCount() > 0) { + int daBottom = getChildAt(0).getBottom(); + int screenBottom = getScrollY() + getHeight() - getPaddingBottom(); + if (daBottom - screenBottom < maxJump) { + scrollDelta = daBottom - screenBottom; + } + } + } + if (scrollDelta == 0) { + return false; + } + doScrollY(direction == View.FOCUS_DOWN ? scrollDelta : -scrollDelta); + } + + if (currentFocused != null && currentFocused.isFocused() + && isOffScreen(currentFocused)) { + // previously focused item still has focus and is off screen, give + // it up (take it back to ourselves) + // (also, need to temporarily force FOCUS_BEFORE_DESCENDANTS so we are + // sure to + // get it) + final int descendantFocusability = getDescendantFocusability(); // save + setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS); + requestFocus(); + setDescendantFocusability(descendantFocusability); // restore + } + return true; + } + + /** + * @return whether the descendant of this scroll view is scrolled off + * screen. + */ + private boolean isOffScreen(View descendant) { + return !isWithinDeltaOfScreen(descendant, 0, getHeight()); + } + + /** + * @return whether the descendant of this scroll view is within delta + * pixels of being on the screen. + */ + private boolean isWithinDeltaOfScreen(View descendant, int delta, int height) { + descendant.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(descendant, mTempRect); + + return (mTempRect.bottom + delta) >= getScrollY() + && (mTempRect.top - delta) <= (getScrollY() + height); + } + + /** + * Smooth scroll by a Y delta + * + * @param delta the number of pixels to scroll by on the Y axis + */ + private void doScrollY(int delta) { + if (delta != 0) { + if (mSmoothScrollingEnabled) { + smoothScrollBy(0, delta); + } else { + scrollBy(0, delta); + } + } + } + + /** + * Like {@link View#scrollBy}, but scroll smoothly instead of immediately. + * + * @param dx the number of pixels to scroll by on the X axis + * @param dy the number of pixels to scroll by on the Y axis + */ + public final void smoothScrollBy(int dx, int dy) { + if (getChildCount() == 0) { + // Nothing to do. + return; + } + long duration = AnimationUtils.currentAnimationTimeMillis() - mLastScroll; + if (duration > ANIMATED_SCROLL_GAP) { + final int height = getHeight() - getPaddingBottom() - getPaddingTop(); + final int bottom = getChildAt(0).getHeight(); + final int maxY = Math.max(0, bottom - height); + final int scrollY = getScrollY(); + dy = Math.max(0, Math.min(scrollY + dy, maxY)) - scrollY; + + mScroller.startScroll(getScrollX(), scrollY, 0, dy); + postInvalidateOnAnimation(); + } else { + if (!mScroller.isFinished()) { + mScroller.abortAnimation(); +// if (mFlingStrictSpan != null) { +// mFlingStrictSpan.finish(); +// mFlingStrictSpan = null; +// } + } + scrollBy(dx, dy); + } + mLastScroll = AnimationUtils.currentAnimationTimeMillis(); + } + + /** + * Like {@link #scrollTo}, but scroll smoothly instead of immediately. + * + * @param x the position where to scroll on the X axis + * @param y the position where to scroll on the Y axis + */ + public final void smoothScrollTo(int x, int y) { + smoothScrollBy(x - getScrollX(), y - getScrollY()); + } + + /** + *

The scroll range of a scroll view is the overall height of all of its + * children.

+ */ + @Override + protected int computeVerticalScrollRange() { + final int count = getChildCount(); + final int contentHeight = getHeight() - getPaddingBottom() - getPaddingTop(); + if (count == 0) { + return contentHeight; + } + + int scrollRange = getChildAt(0).getBottom(); + final int scrollY = getScrollY(); + final int overscrollBottom = Math.max(0, scrollRange - contentHeight); + if (scrollY < 0) { + scrollRange -= scrollY; + } else if (scrollY > overscrollBottom) { + scrollRange += scrollY - overscrollBottom; + } + + return scrollRange; + } + + @Override + protected int computeVerticalScrollOffset() { + return Math.max(0, super.computeVerticalScrollOffset()); + } + + @Override + protected void measureChild(View child, int parentWidthMeasureSpec, + int parentHeightMeasureSpec) { + ViewGroup.LayoutParams lp = child.getLayoutParams(); + + int childWidthMeasureSpec; + int childHeightMeasureSpec; + + childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, getPaddingLeft() + + getPaddingRight(), lp.width); + final int verticalPadding = getPaddingTop() + getPaddingBottom(); + childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - verticalPadding), + MeasureSpec.UNSPECIFIED); + + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + + @Override + protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, + int parentHeightMeasureSpec, int heightUsed) { + final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); + + final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin + + widthUsed, lp.width); + final int usedTotal = getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin + + heightUsed; + final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal), + MeasureSpec.UNSPECIFIED); + + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + + @Override + public void computeScroll() { + if (mScroller.computeScrollOffset()) { + // This is called at drawing time by ViewGroup. We don't want to + // re-show the scrollbars at this point, which scrollTo will do, + // so we replicate most of scrollTo here. + // + // It's a little odd to call onScrollChanged from inside the drawing. + // + // It is, except when you remember that computeScroll() is used to + // animate scrolling. So unless we want to defer the onScrollChanged() + // until the end of the animated scrolling, we don't really have a + // choice here. + // + // I agree. The alternative, which I think would be worse, is to post + // something and tell the subclasses later. This is bad because there + // will be a window where getScrollX()/Y is different from what the app + // thinks it is. + // + int oldX = getScrollX(); + int oldY = getScrollY(); + int x = mScroller.getCurrX(); + int y = mScroller.getCurrY(); + + if (oldX != x || oldY != y) { + final int range = getScrollRange(); + final int overscrollMode = getOverScrollMode(); + final boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS || + (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); + + overScrollBy(x - oldX, y - oldY, oldX, oldY, 0, range, + 0, mOverflingDistance, false); + onScrollChanged(getScrollX(), getScrollY(), oldX, oldY); + + if (canOverscroll) { + if (y < 0 && oldY >= 0) { + if(!onScrollingHitEdge(-mScroller.getCurrVelocity())) + mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity()); + else + mScroller.abortAnimation(); + } else if (y > range && oldY <= range) { + if(!onScrollingHitEdge(mScroller.getCurrVelocity())) + mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity()); + else + mScroller.abortAnimation(); + } + } + } + + if (!awakenScrollBars()) { + // Keep on drawing until the animation has finished. + postInvalidateOnAnimation(); + } + } else { +// if (mFlingStrictSpan != null) { +// mFlingStrictSpan.finish(); +// mFlingStrictSpan = null; +// } + } + } + + /** + * Scrolls the view to the given child. + * + * @param child the View to scroll to + */ + public void scrollToDescendant(@NonNull View child) { + if (!mIsLayoutDirty) { + child.getDrawingRect(mTempRect); + + /* Offset from child's local coordinates to ScrollView coordinates */ + offsetDescendantRectToMyCoords(child, mTempRect); + + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + + if (scrollDelta != 0) { + scrollBy(0, scrollDelta); + } + } else { + mChildToScrollTo = child; + } + } + + /** + * If rect is off screen, scroll just enough to get it (or at least the + * first screen size chunk of it) on screen. + * + * @param rect The rectangle. + * @param immediate True to scroll immediately without animation + * @return true if scrolling was performed + */ + private boolean scrollToChildRect(Rect rect, boolean immediate) { + final int delta = computeScrollDeltaToGetChildRectOnScreen(rect); + final boolean scroll = delta != 0; + if (scroll) { + if (immediate) { + scrollBy(0, delta); + } else { + smoothScrollBy(0, delta); + } + } + return scroll; + } + + /** + * Compute the amount to scroll in the Y direction in order to get + * a rectangle completely on the screen (or, if taller than the screen, + * at least the first screen size chunk of it). + * + * @param rect The rect. + * @return The scroll delta. + */ + protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) { + if (getChildCount() == 0) return 0; + + int height = getHeight(); + int screenTop = getScrollY(); + int screenBottom = screenTop + height; + + int fadingEdge = getVerticalFadingEdgeLength(); + + // leave room for top fading edge as long as rect isn't at very top + if (rect.top > 0) { + screenTop += fadingEdge; + } + + // leave room for bottom fading edge as long as rect isn't at very bottom + if (rect.bottom < getChildAt(0).getHeight()) { + screenBottom -= fadingEdge; + } + + int scrollYDelta = 0; + + if (rect.bottom > screenBottom && rect.top > screenTop) { + // need to move down to get it in view: move down just enough so + // that the entire rectangle is in view (or at least the first + // screen size chunk). + + if (rect.height() > height) { + // just enough to get screen size chunk on + scrollYDelta += (rect.top - screenTop); + } else { + // get entire rect at bottom of screen + scrollYDelta += (rect.bottom - screenBottom); + } + + // make sure we aren't scrolling beyond the end of our content + int bottom = getChildAt(0).getBottom(); + int distanceToBottom = bottom - screenBottom; + scrollYDelta = Math.min(scrollYDelta, distanceToBottom); + + } else if (rect.top < screenTop && rect.bottom < screenBottom) { + // need to move up to get it in view: move up just enough so that + // entire rectangle is in view (or at least the first screen + // size chunk of it). + + if (rect.height() > height) { + // screen size chunk + scrollYDelta -= (screenBottom - rect.bottom); + } else { + // entire rect at top + scrollYDelta -= (screenTop - rect.top); + } + + // make sure we aren't scrolling any further than the top our content + scrollYDelta = Math.max(scrollYDelta, -getScrollY()); + } + return scrollYDelta; + } + + @Override + public void requestChildFocus(View child, View focused) { +// if (focused != null && focused.getRevealOnFocusHint()) { +// if (!mIsLayoutDirty) { +// scrollToDescendant(focused); +// } else { +// // The child may not be laid out yet, we can't compute the scroll yet +// mChildToScrollTo = focused; +// } +// } + super.requestChildFocus(child, focused); + } + + + /** + * When looking for focus in children of a scroll view, need to be a little + * more careful not to give focus to something that is scrolled off screen. + * + * This is more expensive than the default {@link ViewGroup} + * implementation, otherwise this behavior might have been made the default. + */ + @Override + protected boolean onRequestFocusInDescendants(int direction, + Rect previouslyFocusedRect) { + + // convert from forward / backward notation to up / down / left / right + // (ugh). + if (direction == View.FOCUS_FORWARD) { + direction = View.FOCUS_DOWN; + } else if (direction == View.FOCUS_BACKWARD) { + direction = View.FOCUS_UP; + } + + final View nextFocus = previouslyFocusedRect == null ? + FocusFinder.getInstance().findNextFocus(this, null, direction) : + FocusFinder.getInstance().findNextFocusFromRect(this, + previouslyFocusedRect, direction); + + if (nextFocus == null) { + return false; + } + + if (isOffScreen(nextFocus)) { + return false; + } + + return nextFocus.requestFocus(direction, previouslyFocusedRect); + } + + @Override + public boolean requestChildRectangleOnScreen(View child, Rect rectangle, + boolean immediate) { + // offset into coordinate space of this scroll view + rectangle.offset(child.getLeft() - child.getScrollX(), + child.getTop() - child.getScrollY()); + + return scrollToChildRect(rectangle, immediate); + } + + @Override + public void requestLayout() { + mIsLayoutDirty = true; + super.requestLayout(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + +// if (mScrollStrictSpan != null) { +// mScrollStrictSpan.finish(); +// mScrollStrictSpan = null; +// } +// if (mFlingStrictSpan != null) { +// mFlingStrictSpan.finish(); +// mFlingStrictSpan = null; +// } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + mIsLayoutDirty = false; + // Give a child focus if it needs it + if (mChildToScrollTo != null && isViewDescendantOf(mChildToScrollTo, this)) { + scrollToDescendant(mChildToScrollTo); + } + mChildToScrollTo = null; + + if (!isLaidOut()) { + if (mSavedState != null) { + setScrollY(mSavedState.scrollPosition); + mSavedState = null; + } // getScrollY() default value is "0" + + final int childHeight = (getChildCount() > 0) ? getChildAt(0).getMeasuredHeight() : 0; + final int scrollRange = Math.max(0, + childHeight - (b - t - getPaddingBottom() - getPaddingTop())); + + // Don't forget to clamp + if (getScrollY() > scrollRange) { + setScrollY(scrollRange); + } else if (getScrollY() < 0) { + setScrollY(0); + } + } + + // Calling this with the present values causes it to re-claim them + scrollTo(getScrollX(), getScrollY()); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + View currentFocused = findFocus(); + if (null == currentFocused || this == currentFocused) + return; + + // If the currently-focused view was visible on the screen when the + // screen was at the old height, then scroll the screen to make that + // view visible with the new screen height. + if (isWithinDeltaOfScreen(currentFocused, 0, oldh)) { + currentFocused.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(currentFocused, mTempRect); + int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(mTempRect); + doScrollY(scrollDelta); + } + } + + /** + * Return true if child is a descendant of parent, (or equal to the parent). + */ + private static boolean isViewDescendantOf(View child, View parent) { + if (child == parent) { + return true; + } + + final ViewParent theParent = child.getParent(); + return (theParent instanceof ViewGroup) && isViewDescendantOf((View) theParent, parent); + } + + /** + * Fling the scroll view + * + * @param velocityY The initial velocity in the Y direction. Positive + * numbers mean that the finger/cursor is moving down the screen, + * which means we want to scroll towards the top. + */ + public void fling(int velocityY) { + if (getChildCount() > 0) { + int height = getHeight() - getPaddingBottom() - getPaddingTop(); + int bottom = getChildAt(0).getHeight(); + + mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0, + Math.max(0, bottom - height), 0, height/2); + +// if (mFlingStrictSpan == null) { +// mFlingStrictSpan = StrictMode.enterCriticalSpan("ScrollView-fling"); +// } + + postInvalidateOnAnimation(); + } + } + + private void flingWithNestedDispatch(int velocityY) { + final boolean canFling = (getScrollY() > 0 || velocityY > 0) && + (getScrollY() < getScrollRange() || velocityY < 0); + if (!dispatchNestedPreFling(0, velocityY)) { + final boolean consumed = dispatchNestedFling(0, velocityY, canFling); + if (canFling) { + fling(velocityY); + } else if (!consumed) { + if (!mEdgeGlowTop.isFinished()) { + mEdgeGlowTop.onAbsorb(-velocityY); + } else if (!mEdgeGlowBottom.isFinished()) { + mEdgeGlowBottom.onAbsorb(velocityY); + } + } + } + } + +// @UnsupportedAppUsage + private void endDrag() { + mIsBeingDragged = false; + + recycleVelocityTracker(); + + if (shouldDisplayEdgeEffects()) { + mEdgeGlowTop.onRelease(); + mEdgeGlowBottom.onRelease(); + } + +// if (mScrollStrictSpan != null) { +// mScrollStrictSpan.finish(); +// mScrollStrictSpan = null; +// } + } + + /** + * {@inheritDoc} + * + *

This version also clamps the scrolling to the bounds of our child. + */ + @Override + public void scrollTo(int x, int y) { + // we rely on the fact the View.scrollBy calls scrollTo. + if (getChildCount() > 0) { + View child = getChildAt(0); + x = clamp(x, getWidth() - getPaddingRight() - getPaddingLeft(), child.getWidth()); + y = clamp(y, getHeight() - getPaddingBottom() - getPaddingTop(), child.getHeight()); + if (x != getScrollX() || y != getScrollY()) { + super.scrollTo(x, y); + } + } + } + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { + return (nestedScrollAxes & SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onNestedScrollAccepted(View child, View target, int axes) { + super.onNestedScrollAccepted(child, target, axes); + startNestedScroll(SCROLL_AXIS_VERTICAL); + nestedScrollingTarget=target; + } + + /** + * @inheritDoc + */ + @Override + public void onStopNestedScroll(View target) { + super.onStopNestedScroll(target); + nestedScrollingTarget=null; + } + + @Override + public void onNestedScroll(View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + final int oldScrollY = getScrollY(); + scrollBy(0, dyUnconsumed); + final int myConsumed = getScrollY() - oldScrollY; + final int myUnconsumed = dyUnconsumed - myConsumed; + dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null); + } + + /** + * @inheritDoc + */ + @Override + public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (!consumed) { + flingWithNestedDispatch((int) velocityY); + return true; + } + return false; + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (shouldDisplayEdgeEffects()) { + final int scrollY = getScrollY(); + final boolean clipToPadding = getClipToPadding(); + if (!mEdgeGlowTop.isFinished()) { + final int restoreCount = canvas.save(); + final int width; + final int height; + final float translateX; + final float translateY; + if (clipToPadding) { + width = getWidth() - getPaddingLeft() - getPaddingRight(); + height = getHeight() - getPaddingTop() - getPaddingBottom(); + translateX = getPaddingLeft(); + translateY = getPaddingTop(); + } else { + width = getWidth(); + height = getHeight(); + translateX = 0; + translateY = 0; + } + canvas.translate(translateX, Math.min(0, scrollY) + translateY); + mEdgeGlowTop.setSize(width, height); + if (mEdgeGlowTop.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + if (!mEdgeGlowBottom.isFinished()) { + final int restoreCount = canvas.save(); + final int width; + final int height; + final float translateX; + final float translateY; + if (clipToPadding) { + width = getWidth() - getPaddingLeft() - getPaddingRight(); + height = getHeight() - getPaddingTop() - getPaddingBottom(); + translateX = getPaddingLeft(); + translateY = getPaddingTop(); + } else { + width = getWidth(); + height = getHeight(); + translateX = 0; + translateY = 0; + } + canvas.translate(-width + translateX, + Math.max(getScrollRange(), scrollY) + height + translateY); + canvas.rotate(180, width, 0); + mEdgeGlowBottom.setSize(width, height); + if (mEdgeGlowBottom.draw(canvas)) { + postInvalidateOnAnimation(); + } + canvas.restoreToCount(restoreCount); + } + } + } + + private static int clamp(int n, int my, int child) { + if (my >= child || n < 0) { + /* my >= child is this case: + * |--------------- me ---------------| + * |------ child ------| + * or + * |--------------- me ---------------| + * |------ child ------| + * or + * |--------------- me ---------------| + * |------ child ------| + * + * n < 0 is this case: + * |------ me ------| + * |-------- child --------| + * |-- getScrollX() --| + */ + return 0; + } + if ((my+n) > child) { + /* this case: + * |------ me ------| + * |------ child ------| + * |-- getScrollX() --| + */ + return child-my; + } + return n; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (getContext().getApplicationInfo().targetSdkVersion <= VERSION_CODES.JELLY_BEAN_MR2) { + // Some old apps reused IDs in ways they shouldn't have. + // Don't break them, but they don't get scroll state restoration. + super.onRestoreInstanceState(state); + return; + } + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + mSavedState = ss; + requestLayout(); + } + + @Override + protected Parcelable onSaveInstanceState() { + if (getContext().getApplicationInfo().targetSdkVersion <= VERSION_CODES.JELLY_BEAN_MR2) { + // Some old apps reused IDs in ways they shouldn't have. + // Don't break them, but they don't get scroll state restoration. + return super.onSaveInstanceState(); + } + Parcelable superState = super.onSaveInstanceState(); + SavedState ss = new SavedState(superState); + ss.scrollPosition = getScrollY(); + return ss; + } + +// /** @hide */ +// @Override +// protected void encodeProperties(@NonNull ViewHierarchyEncoder encoder) { +// super.encodeProperties(encoder); +// encoder.addProperty("fillViewport", mFillViewport); +// } + + static class SavedState extends BaseSavedState { + public int scrollPosition; + + SavedState(Parcelable superState) { + super(superState); + } + + public SavedState(Parcel source) { + super(source); + scrollPosition = source.readInt(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(scrollPosition); + } + + @Override + public String toString() { + return "ScrollView.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " scrollPosition=" + scrollPosition + "}"; + } + + public static final @NonNull Creator CREATOR + = new Creator() { + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } + + //// What people do to avoid pulling in support libraries. + + protected boolean onScrollingHitEdge(float velocity){ + return false; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestedRecyclerScrollView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestedRecyclerScrollView.java new file mode 100644 index 000000000..0d3f47341 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestedRecyclerScrollView.java @@ -0,0 +1,72 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import java.util.function.Supplier; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +public class NestedRecyclerScrollView extends CustomScrollView{ + private Supplier scrollableChildSupplier; + + public NestedRecyclerScrollView(Context context){ + super(context); + } + + public NestedRecyclerScrollView(Context context, AttributeSet attrs){ + super(context, attrs); + } + + public NestedRecyclerScrollView(Context context, AttributeSet attrs, int defStyleAttr){ + super(context, attrs, defStyleAttr); + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { + final RecyclerView rv = (RecyclerView) target; + if ((dy < 0 && isScrolledToTop(rv)) || (dy > 0 && !isScrolledToBottom())) { + scrollBy(0, dy); + consumed[1] = dy; + return; + } + super.onNestedPreScroll(target, dx, dy, consumed); + } + + @Override + public boolean onNestedPreFling(View target, float velX, float velY) { + final RecyclerView rv = (RecyclerView) target; + if ((velY < 0 && isScrolledToTop(rv)) || (velY > 0 && !isScrolledToBottom())) { + fling((int) velY); + return true; + } + return super.onNestedPreFling(target, velX, velY); + } + + private boolean isScrolledToBottom() { + return !canScrollVertically(1); + } + + private boolean isScrolledToTop(RecyclerView rv) { + final LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager(); + return lm.findFirstVisibleItemPosition() == 0 + && lm.findViewByPosition(0).getTop() == 0; + } + + public void setScrollableChildSupplier(Supplier scrollableChildSupplier){ + this.scrollableChildSupplier=scrollableChildSupplier; + } + + @Override + protected boolean onScrollingHitEdge(float velocity){ + if(velocity>0){ + RecyclerView view=scrollableChildSupplier.get(); + if(view!=null){ + return view.fling(0, (int)velocity); + } + } + return false; + } +} diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_reply_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_reply_24_regular.xml new file mode 100644 index 000000000..86e4845eb --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_reply_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/m3_tabs_line_indicator.xml b/mastodon/src/main/res/drawable/m3_tabs_line_indicator.xml new file mode 100644 index 000000000..f4c2fc78b --- /dev/null +++ b/mastodon/src/main/res/drawable/m3_tabs_line_indicator.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/mastodon/src/main/res/drawable/m3_tabs_rounded_line_indicator.xml b/mastodon/src/main/res/drawable/m3_tabs_rounded_line_indicator.xml new file mode 100644 index 000000000..4f2b2a9e4 --- /dev/null +++ b/mastodon/src/main/res/drawable/m3_tabs_rounded_line_indicator.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/mastodon/src/main/res/drawable/mtrl_tabs_default_indicator.xml b/mastodon/src/main/res/drawable/mtrl_tabs_default_indicator.xml new file mode 100644 index 000000000..68ac74050 --- /dev/null +++ b/mastodon/src/main/res/drawable/mtrl_tabs_default_indicator.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/mastodon/src/main/res/drawable/profile_ava_bg.xml b/mastodon/src/main/res/drawable/profile_ava_bg.xml new file mode 100644 index 000000000..83b8d031e --- /dev/null +++ b/mastodon/src/main/res/drawable/profile_ava_bg.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/tab_indicator_inset.xml b/mastodon/src/main/res/drawable/tab_indicator_inset.xml new file mode 100644 index 000000000..104660ab0 --- /dev/null +++ b/mastodon/src/main/res/drawable/tab_indicator_inset.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/mastodon/src/main/res/layout/design_layout_tab_icon.xml b/mastodon/src/main/res/layout/design_layout_tab_icon.xml new file mode 100644 index 000000000..4c2011bba --- /dev/null +++ b/mastodon/src/main/res/layout/design_layout_tab_icon.xml @@ -0,0 +1,22 @@ + + + + diff --git a/mastodon/src/main/res/layout/design_layout_tab_text.xml b/mastodon/src/main/res/layout/design_layout_tab_text.xml new file mode 100644 index 000000000..56d28d7a5 --- /dev/null +++ b/mastodon/src/main/res/layout/design_layout_tab_text.xml @@ -0,0 +1,23 @@ + + + + diff --git a/mastodon/src/main/res/layout/fragment_profile.xml b/mastodon/src/main/res/layout/fragment_profile.xml new file mode 100644 index 000000000..32117c491 --- /dev/null +++ b/mastodon/src/main/res/layout/fragment_profile.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +