Created
November 29, 2013 03:29
-
-
Save MobileSam/7701218 to your computer and use it in GitHub Desktop.
CardsLib working with StickyListHeaders
Note that I'm using the PR/7 merged with master as I need 2.3 support. https://github.com/gabrielemariotti/cardslib
https://github.com/emilsjolander/StickyListHeaders
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package mobi.dinoz.droid.adapter; | |
import it.gmariotti.cardslib.library.R; | |
import it.gmariotti.cardslib.library.internal.Card; | |
import it.gmariotti.cardslib.library.internal.base.BaseCardArrayAdapter; | |
import it.gmariotti.cardslib.library.view.CardListView; | |
import it.gmariotti.cardslib.library.view.CardView; | |
import it.gmariotti.cardslib.library.view.listener.SwipeDismissListViewTouchListener; | |
import java.util.List; | |
import se.emilsjolander.stickylistheaders.StickyListHeadersAdapter; | |
import android.content.Context; | |
import android.view.LayoutInflater; | |
import android.view.View; | |
import android.view.ViewGroup; | |
import android.widget.AbsListView; | |
import mobi.dinoz.droid.view.HeaderCardListView; | |
public abstract class HeaderCardAdapter extends BaseCardArrayAdapter implements StickyListHeadersAdapter { | |
/** | |
* {@link CardListView} | |
*/ | |
protected HeaderCardListView mCardListView; | |
/** | |
* Listener invoked when a card is swiped | |
*/ | |
protected SwipeDismissListViewTouchListener mOnTouchListener; | |
// ------------------------------------------------------------- | |
// Constructors | |
// ------------------------------------------------------------- | |
/** | |
* Constructor | |
* | |
* @param context | |
* The current context. | |
* @param cards | |
* The cards to represent in the ListView. | |
*/ | |
public HeaderCardAdapter(Context context, List<Card> cards) { | |
super(context, cards); | |
} | |
// ------------------------------------------------------------- | |
// Views | |
// ------------------------------------------------------------- | |
@Override | |
public View getView(int position, View convertView, ViewGroup parent) { | |
View view = convertView; | |
CardView mCardView; | |
Card mCard; | |
LayoutInflater mInflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); | |
// Retrieve card from items | |
mCard = (Card) getItem(position); | |
if (mCard != null) { | |
int layout = mRowLayoutId; | |
boolean recycle = false; | |
// Inflate layout | |
if (view == null) { | |
recycle = false; | |
view = mInflater.inflate(layout, parent, false); | |
} else { | |
recycle = true; | |
} | |
// Setup card | |
mCardView = (CardView) view.findViewById(R.id.list_cardId); | |
if (mCardView != null) { | |
// It is important to set recycle value for performance issue | |
mCardView.setRecycle(recycle); | |
// Save original swipeable to prevent cardSwipeListener | |
// (listView requires another cardSwipeListener) | |
boolean origianlSwipeable = mCard.isSwipeable(); | |
mCard.setSwipeable(false); | |
mCardView.setCard(mCard); | |
// Set originalValue | |
mCard.setSwipeable(origianlSwipeable); | |
// If card has an expandable button override animation | |
if (mCard.getCardHeader() != null && mCard.getCardHeader().isButtonExpandVisible()) { | |
setupExpandCollapseListAnimation(mCardView); | |
} | |
// Setup swipeable animation | |
setupSwipeableAnimation(mCard, mCardView); | |
} | |
} | |
return view; | |
} | |
/** | |
* Sets SwipeAnimation on List | |
* | |
* @param card | |
* {@link Card} | |
* @param cardView | |
* {@link CardView} | |
*/ | |
protected void setupSwipeableAnimation(final Card card, CardView cardView) { | |
if (card.isSwipeable()) { | |
if (mOnTouchListener == null) { | |
throw new IllegalStateException("Not working"); | |
// mOnTouchListener = new | |
// SwipeDismissListViewTouchListener(mCardListView, mCallback); | |
// Setting this scroll listener is required to ensure that | |
// during | |
// ListView scrolling, we don't look for swipes. | |
// mCardListView.setOnScrollListener(mOnTouchListener.makeScrollListener()); | |
} | |
cardView.setOnTouchListener(mOnTouchListener); | |
} else { | |
// prevent issue with recycle view | |
cardView.setOnTouchListener(null); | |
} | |
} | |
/** | |
* Overrides the default collapse/expand animation in a List | |
* | |
* @param cardView | |
* {@link CardView} | |
*/ | |
protected void setupExpandCollapseListAnimation(CardView cardView) { | |
if (cardView == null) | |
return; | |
cardView.setOnExpandListAnimatorListener(mCardListView); | |
} | |
// ------------------------------------------------------------- | |
// SwipeListener | |
// ------------------------------------------------------------- | |
/** | |
* Listener invoked when a card is swiped | |
*/ | |
SwipeDismissListViewTouchListener.DismissCallbacks mCallback = new SwipeDismissListViewTouchListener.DismissCallbacks() { | |
@Override | |
public boolean canDismiss(int position, Card card) { | |
return card.isSwipeable(); | |
} | |
@Override | |
public void onDismiss(AbsListView listView, int[] reverseSortedPositions) { | |
for (int position : reverseSortedPositions) { | |
Card card = getItem(position); | |
if (card.getOnSwipeListener() != null) | |
card.getOnSwipeListener().onSwipe(card); | |
remove(card); | |
} | |
notifyDataSetChanged(); | |
} | |
}; | |
// ------------------------------------------------------------- | |
// Getters and Setters | |
// ------------------------------------------------------------- | |
/** | |
* @return {@link CardListView} | |
*/ | |
public HeaderCardListView getCardListView() { | |
return mCardListView; | |
} | |
/** | |
* Sets the {@link CardListView} | |
* | |
* @param cardListView | |
* cardListView | |
*/ | |
public void setCardListView(HeaderCardListView cardListView) { | |
this.mCardListView = cardListView; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package mobi.dinoz.droid.view; | |
import it.gmariotti.cardslib.library.R; | |
import it.gmariotti.cardslib.library.internal.Card; | |
import it.gmariotti.cardslib.library.view.CardView; | |
import java.util.ArrayList; | |
import java.util.HashMap; | |
import java.util.List; | |
import se.emilsjolander.stickylistheaders.StickyListHeadersAdapter; | |
import se.emilsjolander.stickylistheaders.StickyListHeadersListView; | |
import android.content.Context; | |
import android.content.res.TypedArray; | |
import android.graphics.Canvas; | |
import android.os.Build; | |
import android.util.AttributeSet; | |
import android.util.Log; | |
import android.view.View; | |
import android.view.ViewTreeObserver; | |
import android.widget.AbsListView; | |
import mobi.dinoz.droid.adapter.HeaderCardAdapter; | |
import com.nineoldandroids.animation.Animator; | |
import com.nineoldandroids.animation.AnimatorListenerAdapter; | |
import com.nineoldandroids.animation.AnimatorSet; | |
import com.nineoldandroids.animation.ObjectAnimator; | |
import com.nineoldandroids.animation.PropertyValuesHolder; | |
import com.nineoldandroids.util.FloatProperty; | |
import com.nineoldandroids.util.Property; | |
public class HeaderCardListView extends StickyListHeadersListView implements CardView.OnExpandListAnimatorListener { | |
protected HeaderCardAdapter mAdapter; | |
private boolean mShouldRemoveObserver = false; | |
private List<View> mViewsToDraw = new ArrayList<View>(); | |
private int[] mTranslate; | |
protected int list_card_layout_resourceID = R.layout.list_card_layout; | |
public HeaderCardListView(Context context) { | |
super(context); | |
init(null, 0); | |
} | |
public HeaderCardListView(Context context, AttributeSet attrs) { | |
super(context, attrs); | |
init(attrs, 0); | |
} | |
public HeaderCardListView(Context context, AttributeSet attrs, int defStyle) { | |
super(context, attrs, defStyle); | |
init(attrs, defStyle); | |
} | |
protected void init(AttributeSet attrs, int defStyle) { | |
initAttrs(attrs, defStyle); | |
setDividerHeight(0); | |
} | |
protected void initAttrs(AttributeSet attrs, int defStyle) { | |
list_card_layout_resourceID = R.layout.list_card_layout; | |
TypedArray a = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.card_options, defStyle, | |
defStyle); | |
try { | |
list_card_layout_resourceID = a.getResourceId(R.styleable.card_options_list_card_layout_resourceID, | |
this.list_card_layout_resourceID); | |
} finally { | |
a.recycle(); | |
} | |
} | |
@Override | |
public void setAdapter(StickyListHeadersAdapter adapter) { | |
if (adapter instanceof HeaderCardAdapter) { | |
setAdapter((HeaderCardAdapter) adapter); | |
} else { | |
Log.e("HeaderCardListView", "The CardListView only accepts HeaderCardAdapter"); | |
super.setAdapter(null); | |
} | |
} | |
public void setAdapter(HeaderCardAdapter adapter) { | |
super.setAdapter(adapter); | |
// Set Layout used by items | |
adapter.setRowLayoutId(list_card_layout_resourceID); | |
adapter.setCardListView(this); | |
mAdapter = adapter; | |
} | |
@Override | |
public void onExpandStart(CardView viewCard, View expandingLayout) { | |
prepareExpandView(viewCard, expandingLayout); | |
} | |
@Override | |
public void onCollapseStart(CardView viewCard, View expandingLayout) { | |
prepareCollapseView(viewCard, expandingLayout); | |
} | |
public static final Property<View, Float> ALPHA = new FloatProperty<View>("alpha") { | |
@Override | |
public void setValue(View object, float value) { | |
object.setAlpha(value); | |
} | |
@Override | |
public Float get(View object) { | |
return object.getAlpha(); | |
} | |
}; | |
private void prepareExpandView(final CardView view, final View expandingLayout) { | |
final Card card = (Card) getItemAtPosition(getPositionForView(view)); | |
/* Store the original top and bottom bounds of all the cells. */ | |
final int oldTop = view.getTop(); | |
final int oldBottom = view.getBottom(); | |
final HashMap<View, int[]> oldCoordinates = new HashMap<View, int[]>(); | |
int childCount = getChildCount(); | |
for (int i = 0; i < childCount; i++) { | |
View v = getChildAt(i); | |
if (Build.VERSION.SDK_INT >= 16) { | |
v.setHasTransientState(true); | |
} | |
oldCoordinates.put(v, new int[] { v.getTop(), v.getBottom() }); | |
} | |
/* Update the layout so the extra content becomes visible. */ | |
if (expandingLayout != null) | |
expandingLayout.setVisibility(View.VISIBLE); | |
/* | |
* Add an onPreDraw Listener to the listview. onPreDraw will get invoked | |
* after onLayout and onMeasure have run but before anything has been | |
* drawn. This means that the final post layout properties for all the | |
* items have already been determined, but still have not been rendered | |
* onto the screen. | |
*/ | |
final ViewTreeObserver observer = getViewTreeObserver(); | |
observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { | |
@Override | |
public boolean onPreDraw() { | |
/* Determine if this is the first or second pass. */ | |
if (!mShouldRemoveObserver) { | |
mShouldRemoveObserver = true; | |
/* | |
* Calculate what the parameters should be for | |
* setSelectionFromTop. The ListView must be offset in a | |
* way, such that after the animation takes place, all the | |
* cells that remain visible are rendered completely by the | |
* ListView. | |
*/ | |
int newTop = view.getTop(); | |
int newBottom = view.getBottom(); | |
int newHeight = newBottom - newTop; | |
int oldHeight = oldBottom - oldTop; | |
int delta = newHeight - oldHeight; | |
mTranslate = getTopAndBottomTranslations(oldTop, oldBottom, delta, true); | |
int currentTop = view.getTop(); | |
int futureTop = oldTop - mTranslate[0]; | |
int firstChildStartTop = getChildAt(0).getTop(); | |
int firstVisiblePosition = getFirstVisiblePosition(); | |
int deltaTop = currentTop - futureTop; | |
int i; | |
int childCount = getChildCount(); | |
for (i = 0; i < childCount; i++) { | |
View v = getChildAt(i); | |
int height = v.getBottom() - Math.max(0, v.getTop()); | |
if (deltaTop - height > 0) { | |
firstVisiblePosition++; | |
deltaTop -= height; | |
} else { | |
break; | |
} | |
} | |
if (i > 0) { | |
firstChildStartTop = 0; | |
} | |
setSelectionFromTop(firstVisiblePosition, firstChildStartTop - deltaTop); | |
/* | |
* Request another layout to update the layout parameters of | |
* the cells. | |
*/ | |
requestLayout(); | |
/* | |
* Return false such that the ListView does not redraw its | |
* contents on this layout but only updates all the | |
* parameters associated with its children. | |
*/ | |
return false; | |
} | |
/* | |
* Remove the predraw listener so this method does not keep | |
* getting called. | |
*/ | |
mShouldRemoveObserver = false; | |
observer.removeOnPreDrawListener(this); | |
int yTranslateTop = mTranslate[0]; | |
int yTranslateBottom = mTranslate[1]; | |
ArrayList<Animator> animations = new ArrayList<Animator>(); | |
int index = indexOfChild(view); | |
/* | |
* Loop through all the views that were on the screen before the | |
* cell was expanded. Some cells will still be children of the | |
* ListView while others will not. The cells that remain | |
* children of the ListView simply have their bounds animated | |
* appropriately. The cells that are no longer children of the | |
* ListView also have their bounds animated, but must also be | |
* added to a list of views which will be drawn in dispatchDraw. | |
*/ | |
for (View v : oldCoordinates.keySet()) { | |
int[] old = oldCoordinates.get(v); | |
v.setTop(old[0]); | |
v.setBottom(old[1]); | |
if (v.getParent() == null) { | |
mViewsToDraw.add(v); | |
int delta = old[0] < oldTop ? -yTranslateTop : yTranslateBottom; | |
animations.add(getAnimation(v, delta, delta)); | |
} else { | |
int i = indexOfChild(v); | |
if (v != view) { | |
int delta = i > index ? yTranslateBottom : -yTranslateTop; | |
animations.add(getAnimation(v, delta, delta)); | |
} | |
v.setHasTransientState(false); | |
} | |
} | |
/* Adds animation for expanding the cell that was clicked. */ | |
animations.add(getAnimation(view, -yTranslateTop, yTranslateBottom)); | |
/* Adds an animation for fading in the extra content. */ | |
animations.add(ObjectAnimator.ofFloat(expandingLayout, ALPHA, 0, 1)); | |
/* Disabled the ListView for the duration of the animation. */ | |
setEnabled(false); | |
setClickable(false); | |
/* | |
* Play all the animations created above together at the same | |
* time. | |
*/ | |
AnimatorSet s = new AnimatorSet(); | |
s.playTogether(animations); | |
s.addListener(new AnimatorListenerAdapter() { | |
@Override | |
public void onAnimationEnd(Animator animation) { | |
view.setExpanded(true);// card.setExpanded(true); | |
setEnabled(true); | |
setClickable(true); | |
if (mViewsToDraw.size() > 0) { | |
for (View v : mViewsToDraw) { | |
if (Build.VERSION.SDK_INT >= 16) { | |
v.setHasTransientState(false); | |
} | |
} | |
} | |
mViewsToDraw.clear(); | |
if (card.getOnExpandAnimatorEndListener() != null) | |
card.getOnExpandAnimatorEndListener().onExpandEnd(card); | |
} | |
}); | |
s.start(); | |
return true; | |
} | |
}); | |
} | |
private void prepareCollapseView(final CardView view, final View expandingLayout) { | |
final Card card = (Card) getItemAtPosition(getPositionForView(view)); | |
/* Store the original top and bottom bounds of all the cells. */ | |
final int oldTop = view.getTop(); | |
final int oldBottom = view.getBottom(); | |
final HashMap<View, int[]> oldCoordinates = new HashMap<View, int[]>(); | |
int childCount = getChildCount(); | |
for (int i = 0; i < childCount; i++) { | |
View v = getChildAt(i); | |
if (Build.VERSION.SDK_INT >= 16) { | |
v.setHasTransientState(true); | |
} | |
oldCoordinates.put(v, new int[] { v.getTop(), v.getBottom() }); | |
} | |
/* Update the layout so the extra content becomes invisible. */ | |
view.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, view | |
.getCollapsedHeight())); | |
/* Add an onPreDraw listener. */ | |
final ViewTreeObserver observer = getViewTreeObserver(); | |
observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { | |
@Override | |
public boolean onPreDraw() { | |
if (!mShouldRemoveObserver) { | |
/* | |
* Same as for expandingView, the parameters for | |
* setSelectionFromTop must be determined such that the | |
* necessary cells of the ListView are rendered and added to | |
* it. | |
*/ | |
mShouldRemoveObserver = true; | |
int newTop = view.getTop(); | |
int newBottom = view.getBottom(); | |
int newHeight = newBottom - newTop; | |
int oldHeight = oldBottom - oldTop; | |
int deltaHeight = oldHeight - newHeight; | |
mTranslate = getTopAndBottomTranslations(oldTop, oldBottom, deltaHeight, false); | |
int currentTop = view.getTop(); | |
int futureTop = oldTop + mTranslate[0]; | |
int firstChildStartTop = getChildAt(0).getTop(); | |
int firstVisiblePosition = getFirstVisiblePosition(); | |
int deltaTop = currentTop - futureTop; | |
int i; | |
int childCount = getChildCount(); | |
for (i = 0; i < childCount; i++) { | |
View v = getChildAt(i); | |
int height = v.getBottom() - Math.max(0, v.getTop()); | |
if (deltaTop - height > 0) { | |
firstVisiblePosition++; | |
deltaTop -= height; | |
} else { | |
break; | |
} | |
} | |
if (i > 0) { | |
firstChildStartTop = 0; | |
} | |
setSelectionFromTop(firstVisiblePosition, firstChildStartTop - deltaTop); | |
requestLayout(); | |
return false; | |
} | |
mShouldRemoveObserver = false; | |
observer.removeOnPreDrawListener(this); | |
int yTranslateTop = mTranslate[0]; | |
int yTranslateBottom = mTranslate[1]; | |
int index = indexOfChild(view); | |
int childCount = getChildCount(); | |
for (int i = 0; i < childCount; i++) { | |
View v = getChildAt(i); | |
int[] old = oldCoordinates.get(v); | |
if (old != null) { | |
/* | |
* If the cell was present in the ListView before the | |
* collapse and after the collapse then the bounds are | |
* reset to their old values. | |
*/ | |
v.setTop(old[0]); | |
v.setBottom(old[1]); | |
if (Build.VERSION.SDK_INT >= 16) { | |
v.setHasTransientState(false); | |
} | |
} else { | |
/* | |
* If the cell is present in the ListView after the | |
* collapse but not before the collapse then the bounds | |
* are calculated using the bottom and top translation | |
* of the collapsing cell. | |
*/ | |
int delta = i > index ? yTranslateBottom : -yTranslateTop; | |
v.setTop(v.getTop() + delta); | |
v.setBottom(v.getBottom() + delta); | |
} | |
} | |
/* | |
* Animates all the cells present on the screen after the | |
* collapse. | |
*/ | |
ArrayList<Animator> animations = new ArrayList<Animator>(); | |
for (int i = 0; i < childCount; i++) { | |
View v = getChildAt(i); | |
if (v != view) { | |
float diff = i > index ? -yTranslateBottom : yTranslateTop; | |
animations.add(getAnimation(v, diff, diff)); | |
} | |
} | |
/* | |
* ValueAnimator animator = ValueAnimator.ofInt( | |
* yTranslateTop,-yTranslateBottom); | |
* animator.addUpdateListener(new | |
* ValueAnimator.AnimatorUpdateListener() { | |
* | |
* @Override public void onAnimationUpdate(ValueAnimator | |
* valueAnimator) { int value = (Integer) | |
* valueAnimator.getAnimatedValue(); | |
* | |
* ViewGroup.LayoutParams layoutParams = | |
* expandingLayout.getLayoutParams(); layoutParams.height = | |
* value; expandingLayout.setLayoutParams(layoutParams); } }); | |
* animations.add(animator); | |
*/ | |
/* Adds animation for collapsing the cell that was clicked. */ | |
animations.add(getAnimation(view, yTranslateTop, -yTranslateBottom)); | |
/* Adds an animation for fading out the extra content. */ | |
animations.add(ObjectAnimator.ofFloat(expandingLayout, ALPHA, 1, 0)); | |
/* Disabled the ListView for the duration of the animation. */ | |
setEnabled(false); | |
setClickable(false); | |
/* | |
* Play all the animations created above together at the same | |
* time. | |
*/ | |
AnimatorSet s = new AnimatorSet(); | |
s.playTogether(animations); | |
s.addListener(new AnimatorListenerAdapter() { | |
@Override | |
public void onAnimationEnd(Animator animation) { | |
expandingLayout.setVisibility(View.GONE); | |
view.setLayoutParams(new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, | |
AbsListView.LayoutParams.WRAP_CONTENT)); | |
view.setExpanded(false); | |
setEnabled(true); | |
setClickable(true); | |
/* | |
* Note that alpha must be set back to 1 in case this | |
* view is reused by a cell that was expanded, but not | |
* yet collapsed, so its state should persist in an | |
* expanded state with the extra content visible. | |
*/ | |
expandingLayout.setAlpha(1); | |
if (card.getOnCollapseAnimatorEndListener() != null) | |
card.getOnCollapseAnimatorEndListener().onCollapseEnd(card); | |
} | |
}); | |
s.start(); | |
return true; | |
} | |
}); | |
} | |
private int[] getTopAndBottomTranslations(int top, int bottom, int yDelta, boolean isExpanding) { | |
int yTranslateTop = 0; | |
int yTranslateBottom = yDelta; | |
int height = bottom - top; | |
if (isExpanding) { | |
boolean isOverTop = top < 0; | |
boolean isBelowBottom = (top + height + yDelta) > getHeight(); | |
if (isOverTop) { | |
yTranslateTop = top; | |
yTranslateBottom = yDelta - yTranslateTop; | |
} else if (isBelowBottom) { | |
int deltaBelow = top + height + yDelta - getHeight(); | |
yTranslateTop = top - deltaBelow < 0 ? top : deltaBelow; | |
yTranslateBottom = yDelta - yTranslateTop; | |
} | |
} else { | |
int offset = computeVerticalScrollOffset(); | |
int range = computeVerticalScrollRange(); | |
int extent = computeVerticalScrollExtent(); | |
int leftoverExtent = range - offset - extent; | |
boolean isCollapsingBelowBottom = (yTranslateBottom > leftoverExtent); | |
boolean isCellCompletelyDisappearing = bottom - yTranslateBottom < 0; | |
if (isCollapsingBelowBottom) { | |
yTranslateTop = yTranslateBottom - leftoverExtent; | |
yTranslateBottom = yDelta - yTranslateTop; | |
} else if (isCellCompletelyDisappearing) { | |
yTranslateBottom = bottom; | |
yTranslateTop = yDelta - yTranslateBottom; | |
} | |
} | |
return new int[] { yTranslateTop, yTranslateBottom }; | |
} | |
private Animator getAnimation(final View view, float translateTop, float translateBottom) { | |
int top = view.getTop(); | |
int bottom = view.getBottom(); | |
int endTop = (int) (top + translateTop); | |
int endBottom = (int) (bottom + translateBottom); | |
PropertyValuesHolder translationTop = PropertyValuesHolder.ofInt("top", top, endTop); | |
PropertyValuesHolder translationBottom = PropertyValuesHolder.ofInt("bottom", bottom, endBottom); | |
return ObjectAnimator.ofPropertyValuesHolder(view, translationTop, translationBottom); | |
} | |
@Override | |
protected void dispatchDraw(Canvas canvas) { | |
super.dispatchDraw(canvas); | |
if (mViewsToDraw.size() == 0) { | |
return; | |
} | |
for (View v : mViewsToDraw) { | |
canvas.translate(0, v.getTop()); | |
v.draw(canvas); | |
canvas.translate(0, -v.getTop()); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment