目录-Content

1. 介绍-Introduction

我本来是打算就做一个 Android 的文件管理器的,但是文件的结构本身就是树结构。所以才写了 Tree2View 这个自定义 View。而文件管理器只是作为用这个项目实现的例子。

还有另外一个原因是:在 javax.swing.JTree 里是一个树状的 swing 组件,但是 Android 里却没有一个内置的树状组件,所以就决定造一个。那么 talking is cheap, show me the code.开始吧。

See on Github

See on My blog

2. 主要功能-Features

Tree2View Android FileExplorer
①多级分层的树结构视图 基本的文件管理器布局
②记忆展开的状态 自动展开上次打开未关闭的目录
③使用适配器设计模式,用户可自定义 TreeAdapter 对不同类型的文件显示不同的Icon
④动态增删节点 删除和添加文件后可自动刷新状态
⑤选择模式 长按节点进行文件操作(Copy, Cut, Rename, Delete)

2.1 功能展示-Preview

image

3. 源码分析-Source-code

  • Tree2View 继承自 ListView

  • 分级的视觉效果通过 SimpleTreeAdapter(通过对不同深度的节点设置不同的缩进)来实现的。

  • 使用上直接使用 DefaultTreeNode (使用链表实现子节点之间的联系)增删节点。这样就实现了视觉效果和数据结构统一的设计。

  • 动态增删节点,ListView本身的特性,需要Adapter。

  • 得益于 Adapter 设计模式 Tree2View 可以由用户定制实现。

我们接着来看一下 UML 图,图中省略了一下方法和字段。

UML

3.1 树结构-Tree-Data-structure

树由节点和边组成,并且不能有环(为了简单,我就暂时这样想)。因为链表插入和删除时间复杂度都是 O(1),而这里又是要频繁的进行节点的插入和删除的,所以我用了链表来保存一个节点的孩子们。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DefaultTreeNode<E> implements
    TreeNode, Serializable, Cloneabl{
  //max = 1 << 31
  private int id;
  private int lastId;

  //The content of this node
  private transient E element;
  private DefaultTreeNode<E> parent = null;

  //array of node's child
  private LinkedList<DefaultTreeNode> children;

  //just a mark, even node has children,
  //this value can still be false.
  boolean hasChildren = false;
  //same as hasChildren, but it's default value is true.
  //it let an expandable node can add children, after add the hasChildren will be true
  private boolean mExpandable = true;
  //whether this node is expanded, always false when (mExpandable == false)
  private boolean isExpanded = false;
  private boolean mSelectable = true;
  private boolean isSelected = false;
}

上面是 DefaultTreeNode 的字段,需要注意的点我都写进了注释里。DefaultTreeNode 持有她的父节点,子节点们和用户的数据。为了让用户可以更方便的自定义,这里还有一个 TreeNode 接口。如果你想自己写一个节点,也是可以的,只要实现了 TreeNode。。。

3.2 主要算法-Alogrithm-in-Tree2View

那么基本的树节点已经有了,节点间的关系也通过链表联系了起来。接下来关键是如何把节点都遍历出来,然后显示出来。如果这里用全局遍历,也就是一次性遍历树里所有的节点,我做过尝试,文件数目在10000的级别,加载就需要 4-5 秒。所以不能一次性遍历。所以我这里用到了 Lazy loading 的思想,只加载需要显示出来的节点,并在点击的时候创建并加载她的孩子们,已经遍历过的节点,第二次加载时是不会再去创建节点的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  /**
   * DFS use <code>Stack</code>
   */
  public static ArrayList<DefaultTreeNode> getVisibleNodesD(DefaultTreeNode aRoot) {
    ArrayList<DefaultTreeNode> list = new ArrayList<>();
    Stack<DefaultTreeNode> stack = new Stack<>();
    if (aRoot == null) {
      return null;
    }
    stack.push(aRoot);
    while (!stack.isEmpty()) {
      DefaultTreeNode node = stack.pop();
      list.add(node);
      LinkedList<DefaultTreeNode> children = node.getChildren();
      if (children == null) {
        continue;
      }
      //add children in reversed order
      for (int i = children.size() - 1; i >= 0; i--) {
        if (node.isExpanded()) {
          stack.push(children.get(i));
        }
      }
    }
    return list;
  }

  /**
   * BFS use <code>Queue</code>
   * @return ArrayList of <code>DefaultTreeNode</code>
   */
  public static ArrayList<DefaultTreeNode> getVisibleNodesB(DefaultTreeNode aRoot){}

DefaultTreeNode 的 isExpanded 标记就是用来表示这个节点的子节点们是否需要加载的,所以默认设置了根节点的 isExpanded = true。如果需要加载就通过 getVisibleNodesD(DefaultTreeNode) 这个方法DFS遍历把他们转化为数组,这里为什么需要是数组呢?因为 ListView 内部的子 View 们是存储在数组里的,为了方便需要加载的数据就也用数组了。

动态加载节点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
      if (!node.isHasChildren()) {
        Log.w(TAG, "onItemClick: not Expandable");
      } else {
        //toggle
        if (node.isExpanded()) {
          node.setExpanded(false);
          adapter.setNodesList(TreeUtils.getVisibleNodesD(root));
          TreeView.this.refresh(null);
        } else {
          node.setExpanded(true);
          Log.d(TAG, "onItemClick: open");
          //create children
          createNode(node)
          //update view
          adapter.setNodesList(TreeUtils.getVisibleNodesD(root));
          TreeView.this.refresh(null);
        }
      }

3.3 适配器模式-Adapter-pattern-in-Tree2View

到这里,节点已经遍历出来了,接下来解释如何显示出来。ListView 本身使用的就是适配器模式, 所以要支持自定义很简单,只要实现自己的 adapter 就可以。

Tree2View 实现了一个默认的 SimpleTreeAdapter 只能显示文字。

SimpleTreeAdapter 显示的就是一个 TextView,所以 DefaultTreeNode 也只需要一个 String,只需在继承的时候这样写 extends TreeAdapter<String>,那么你的 DefaultTreeNode 也必须是 DefaultTreeNode<String> 不过这个大概无关紧要,java 的泛型也只是编译器帮你进行了类型转换的语法糖。

那么接下来看一下最最核心的 getView() 方法。这个方法申明在 Adapter 接口里,然后大家一层一层继承下来,但都不实现,最终到了 SimpleTreeAdapter。各个参数分别是 position, convertView, parent,分别是 ItemView 在 ViewGroup 里的位置,convertView 就是需要返回的 ItemView, parent 就是 ViewGroup,也就是 TreeView 本身。这里为每个 ItemView 设置了缩进,这也是最核心的部分,树状的树图,就是靠缩进来实现的,那么 TreeView 其实就是一个根据不同节点的深度为 ItemView 设置了不同缩进的 ListView。这里就是一个简单的 TextView 显示每个节点的 String,再在 TreeView 里把 SimpleTreeAdapter 作为默认的 Adapter。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class SimpleTreeAdapter
  extends TreeAdapter<String> {

  public SimpleTreeAdapter(Context context,
       DefaultTreeNode<String> root, int resourceId) {
    super(context, root, resourceId);
  }

  @Override
  public View getView(int position,
       View convertView, ViewGroup parent) {
    //dfs travel when first time called
    if (mNodesList == null) {
      mNodesList = TreeUtils.getVisibleNodesD(super.mRoot);
    }
    DefaultTreeNode node = mNodesList.get(position);
    ViewHolder holder = null;

    if (convertView == null) {
      convertView = LayoutInflater.from(mContext).inflate(mResourceId, parent, false);
      holder = new ViewHolder();
      holder.tv = (TextView) convertView.findViewById(R.id.default_tree_item);
      convertView.setTag(holder);
    } else {
      holder = (ViewHolder) convertView.getTag();
    }
    holder.tv.setText(node.getElement().toString());
    int depth = node.getDepth();
    //set view indent
    setPadding(holder.tv, depth, -1);
    //toggle
    toggle(holder, node);
    return convertView;
  }

  @Override
  public void toggle(Object... objects) {
    try {
      DefaultTreeNode node = (DefaultTreeNode) objects[0];
      ViewHolder holder = (ViewHolder) objects[1];
      if (node.isHasChildren() && !node.isExpanded()) {
        //set your icon
      } else if (node.isHasChildren() && node.isExpanded()) {
        //set your icon
      }
    } catch (ClassCastException e ) {
      e.printStackTrace();
    }
  }

  class ViewHolder {
    TextView tv;
  }
}

最核心的方法:通过对不同深度的节点设置不同的缩进,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public abstract class TreeAdapter<T>
     extends BaseAdapter {
  protected void setPadding(View v, int depth, int indent) {
    if (v == null || depth < 0) {
      throw new IllegalArgumentException("illegal params");
    }
    if (indent < 0) {
      indent = baseIndent;
    }
    v.setPadding(indent * (depth + 1),
            v.getPaddingTop(),
            v.getPaddingRight(),
            v.getPaddingBottom());
  }
}

那么从一开始的创建节点到现在加载数据到 View,Tree2View就这样实现了,用到了适配器模式,数据结构的链表,队列,栈,DFS和 BFS遍历。不过应该还有许多可以改进的地方,于是我也在 Github 上开源了,欢迎提 issuse 和 pull request

3.4 文件管理器-FileExplorer

Android 的文件管理器作为 Tree2View 的一个例子,而不是重点。直接用 java 的 File Api。

我用了 Kotlin 而不是 Java,我们直接看代码,省略了一些代码,需要看可以点击上面的链接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    tree_view.setTreeItemSelectedListener { _, node, pos ->
      val list = TreeUtils.getVisibleNodesD(root)
      val fileItem: FileItem = list[pos].element as FileItem
      val file = File(fileItem.absName)
      toast("You selected " + fileItem.name)
      if (node.isSelectable) {
        when (selectedMod) {
          0 -> {
            selector("File operations", fileOps, { dialogInterface, i ->
              run {
                lastSelectedNode = node
                    as DefaultTreeNode<FileItem>?
                when (i) {
                  0 -> {
                    //复制文件
                    Log.d(TAG, "Copy")
                    toast("Selected a folder to copy to.")
                    selectedMod = 1
                    lastClickedFileName = fileItem.absName
                  }
                  1 -> {
                    //剪贴文件
                    Log.d(TAG, "Cut")
                    toast("Selected a folder to move to.")
                    selectedMod = 2
                    lastClickedFileName = fileItem.absName
                  }
                  2 -> {
                    //重命名文件,省略代码
                    Log.d(TAG, "Rename")
                  }
                  3 -> {
                    //删除文件,省略代码
                    Log.d(TAG, "Delete")
                  }
                  else -> {
                  }
                }
              }
            })
          }

          1 -> {
            //select copy dest dir,省略代码
          }

          2 -> {
            //select cut dest dir,省略代码
          }
        }
      }
      false
    }

4.使用-Usage

4.1 下载

未上传到 jCenter(),可直接 clone 本项目使用。

1
 git clone [email protected]:LeeReindeer/Tree2View.git

Then open your project in Android Studio, and Click FIle -> New -> Import Module, to import this module.

Finally, add dependence in your build.gradle

1
 implementation project(path: ':treeview')

4.2 简单使用

Feel free to use it as ListView.

XML:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

    <xyz.leezoom.view.treeview.TreeView
        android:id="@+id/tree_view"
        android:layout_marginTop="16dp"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:divider="#ffffff"
        android:dividerHeight="1px">

    </xyz.leezoom.view.treeview.TreeView>

Kotlin(Java is similar whit it):

1
2
3
4
5
6
7
8
9

  var root :DefaultTreeNode? = DefaultTreeNode("Root")
  val treeView = TreeView(this@MainActivity, root)
  val child1 = DefaultTreeNode("Child1")
  val child2 = DefaultTreeNode("Child2")
  root.addChild(child1)
  root.addChild(child2)
  val child3 = DefaultTreeNode("Child3")
  child1.addChild(child3)

So above code will create a tree like this:

4.3 进阶使用

If you want to use customized item view, you should implement TreeAapater, like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class FileTreeAdapter extends TreeAdapter<FileItem> {
  //resourceId is your customized view resourceId, please use RelativeLayout, and let view neighbour.
  public FileTreeAdapter(Context context, DefaultTreeNode root, int resourceId) {
    super(context, root, resourceId);
  }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
     //your code here
     //...
     //call padding for a better UI
     setPadding(holder.arrowIcon, depth, -1);
     //toggle your view's status here
     toggle(node, holder);
    }

    @Override
     public void toggle(Object... objects) {
     }

Then simply add:

1
2
  val adapter = FileTreeAdapter(this@MainActivity, root, R.layout.layout_file_tree_item)
  treeView.treeAdapter = adapter

5. Open-Source

6. Liscense

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  Copyright 2017 LeeReindeer <[email protected]>

  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.