This commit is contained in:
Grishka
2022-02-07 15:07:12 +03:00
parent cc06715aa6
commit aa193b8921
42 changed files with 7573 additions and 23 deletions

View File

@@ -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<T> extends APIRequest<T>{
private String path;
private String method;
private Object requestBody;
private Map<String, String> queryParams;
private List<Pair<String, String>> queryParams;
Class<T> respClass;
TypeToken<T> respTypeToken;
Call okhttpCall;
@@ -86,8 +88,8 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
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<T> extends APIRequest<T>{
.authority(domain)
.path(getPathPrefix()+path);
if(queryParams!=null){
for(Map.Entry<String, String> param:queryParams.entrySet()){
builder.appendQueryParameter(param.getKey(), param.getValue());
for(Pair<String, String> param:queryParams){
builder.appendQueryParameter(param.first, param.second);
}
}
return builder.build();

View File

@@ -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<List<Relationship>>{
public GetAccountRelationships(@NonNull List<String> ids){
super(HttpMethod.GET, "/accounts/relationships", new TypeToken<>(){});
for(String id:ids)
addQueryParameter("id[]", id);
}
}

View File

@@ -7,8 +7,10 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
import androidx.annotation.NonNull;
public class GetAccountStatuses extends MastodonAPIRequest<List<Status>>{
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<List<Status>>{
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
}
}

View File

@@ -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;
}

View File

@@ -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<Status> result){
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
}
}

View File

@@ -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);
}
}

View File

@@ -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<Status> 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<Relationship> 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<SimpleViewHolder>{
@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);
}
}
}

View File

@@ -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{"+

View File

@@ -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+'\''+
'}';
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.
*
* <p>By default, this class will size the indicator according to {@link
* TabLayout#isTabIndicatorFullWidth()} and linearly move the indicator between tabs.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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);
}
}

View File

@@ -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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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.
*
* <p>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.
*
* <p>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<TabLayout> 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();
}
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<RecyclerView> 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<RecyclerView> 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;
}
}