├── DiskLruCache ├── README.md └── sources │ └── com │ └── cyandev │ ├── DiskLruCache.java │ └── test │ └── DiskLruCacheTest.java ├── ImageMagicMove ├── README.md ├── assets │ └── demo.gif └── demo │ └── ImageTransitionActivity.java ├── LICENSE ├── README.md ├── SwipeRefreshLayout ├── README.md └── SwipeRefreshLayout.java └── UnderstandingFitsSystemWindows ├── README.md └── assets ├── 1.png ├── 2.png └── 3.png /DiskLruCache/README.md: -------------------------------------------------------------------------------- 1 | # DiskLruCache 实现分析 2 | 3 | ## 前言 4 | 5 | 本文中的 `DiskLruCache` 实现来自 AOSP,同时也被 OkHttp 的缓存系统所采用。这个类,顾名思义就是能够将缓存数据持久化到磁盘上,并使用 LRU 淘汰算法来维持缓存保持在一个稳定的大小。 6 | 7 | 这里想多谈一谈缓存。 8 | 9 | 很多文章中会提到一些诸如 “图片三级缓存” 这样的话题,在我看来,应用需要实现好的其实就两级缓存:内存缓存和磁盘缓存。图片从网络下载下来之后首先放置在内存中做缓存,此时的内存缓存分为两种,一种是未解码数据的缓存(即 JPG、PNG、GIF 文件的二进制字节);另外一种则是解码后的图像像素数据的缓存,这类数据通常很大(可以用 `Bitmap#getByteCount()` 来估测占用内存的大小),各类缓存池的大小需要在开发的时候控制好。 10 | 11 | `DiskLruCache` 充当了所有缓存中的最后一级(不讨论网络缓存),也是容量最大的缓存,通常可以大量存放一些图片、音频等数据,当内存缓存均不命中时可以调取。我们知道,Android SDK 和 Support Library 提供了 `LruCache` 这个类,来让我们更方便地去实现内存 LRU 缓存,但 `DiskLruCache` 并没有提供,大家需要手动从 AOSP 中提取,也可以从本文的[参考源码](https://github.com/unixzii/android-source-codes/blob/master/DiskLruCache/sources/com/cyandev/DiskLruCache.java)中下载。 12 | 13 | ## 使用方法 14 | `DiskLruCache` 的使用方法十分简单,本文就不再赘述了,不熟悉的同学可以先看一下我写的一个简单的[单元测试](https://github.com/unixzii/android-source-codes/blob/master/DiskLruCache/sources/com/cyandev/test/DiskLruCacheTest.java)。 15 | 16 | ## 有关 LinkedHashMap 的预备知识 17 | `DiskLruCache` 内部采用 `LinkedHashMap` 来进行缓存条目的存储,这里的条目对应的就是 `DiskLruCache$Entry` 这个类,大家可以把它想成缓存文件的索引。当我们想要获取一个条目时,首先就会查询哈希表里是否有相应的 Entry,查到 Entry 之后再为其创建 `Snapshot` 或 `Editor` 对象,这些我们后面会提到。 18 | 19 | 这里比较核心的就是 LRU 这个算法的实现,简单来说 LRU 会在缓存满时不断淘汰最少使用的条目,直到缓存大小不超过最大限制。要实现这个功能,就需要我们统计条目的访问情况,这里 `DiskLruCache` 的逻辑比较简单,即:当访问一个条目时,把它放到整个链表的尾部,当清理条目时,从链表的开头开始清理,这样最少被访问的条目就会被优先清理掉。`DiskLruCache ` 中的实现如下: 20 | 21 | ```java 22 | private void trimToSize() throws IOException { 23 | while (size > maxSize) { 24 | final Map.Entry toEvict = lruEntries.entrySet().iterator().next(); 25 | remove(toEvict.getKey()); 26 | } 27 | } 28 | ``` 29 | 30 | 访问条目的实现如下: 31 | 32 | ```java 33 | public synchronized Snapshot get(String key) throws IOException { 34 | ... 35 | 36 | Entry entry = lruEntries.get(key); 37 | 38 | ... 39 | } 40 | ``` 41 | 42 | 并没有在链表中移动的操作。其实这就是 `LinkedHashMap` 和 `HashMap` 的一个比较大的区别,`LinkedHashMap` 中的一个构造函数包含了一个参数,用来控制遍历时的顺序,当使用访问顺序来遍历时,每次访问条目,`LinkedHashMap` 都会将其移至链表的尾部: 43 | 44 | ```java 45 | public V get(Object key) { 46 | Node e; 47 | if ((e = getNode(hash(key), key)) == null) 48 | return null; 49 | if (accessOrder) 50 | afterNodeAccess(e); 51 | return e.value; 52 | } 53 | ``` 54 | 55 | 这里 `afterNodeAccess` 就是一个双向链表中移动节点的操作。 56 | 57 | 至此我们就应该能明白 `DiskLruCache` 管理内存中的条目索引的方法了。 58 | 59 | ## 日志文件 60 | 日志文件记录了 `DiskLruCache` 的各种活动,是纯文本文件,格式诸如: 61 | 62 | ```plain 63 | libcore.io.DiskLruCache 64 | 1 65 | 100 66 | 2 67 | 68 | CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 69 | DIRTY 335c4c6028171cfddfbaae1a9c313c52 70 | CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342 71 | REMOVE 335c4c6028171cfddfbaae1a9c313c52 72 | DIRTY 1ab96a171faeeee38496d8b330771a7a 73 | CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234 74 | READ 335c4c6028171cfddfbaae1a9c313c52 75 | READ 3400330d1dfc7f3f7f4b8d4d803dfcf6 76 | ``` 77 | 78 | 第一行充当了 **"magic number"** 的角色,说明了此文件为 `DiskLruCache` 的日志,第二行则是类的版本号,第三行是客户端应用的版本号,第四行为每个缓存条目的文件数。 79 | 80 | 接下来的所有行就记录了之前 `DiskLruCache` 的一系列行为,在初始化的时候通过读取日志文件,就可以重建整个条目索引了。 81 | 82 | 日志中总共会出现四种状态: 83 | 84 | * **DIRTY:** 记录了某一个条目开始被编辑,有可能是新增条目,也有可能是修改已有条目。接下来需要有 **CLEAN** 或 **REMOVE** 状态来平衡 **DIRTY** 状态,否则这就是无效的一条记录。 85 | * **CLEAN:** 表明之前的某一 **DIRTY** 记录已经修改完毕(通常是执行 `Editor#commit()` 的结果),这一行结尾会有条目的各个文件的长度。 86 | * **READ:** 记录了某一条的读取行为。记录读取行为的目的是为了在重构条目索引时将对应的节点移动到链表的尾部,以便于 LRU 算法的执行。 87 | * **REMOVE:** 记录了某一条目的移除行为。 88 | 89 | 本文不打算展开介绍日志文件的生成与解析,大家有兴趣的话可以自行看源码,实现原理也很简单。值得注意的是,`DiskLruCache` 的很多文件操作都采用了事务的处理方式,即修改文件前先写入一个同名的 tmp 文件,当所有内容写完后再将 tmp 文件的扩展名去掉以覆盖原有文件,这样做的好处就是不会因为应用的异常退出或 crash 而出现 **Corrupt Data**,保证了原有文件的完整性。 90 | 91 | ## 初始化工作 92 | `DiskLruCache` 实例必须通过 `DiskLruCache#open(File, int, int, long)` 这个静态方法创建,其实现如下: 93 | 94 | ```java 95 | public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) 96 | throws IOException { 97 | if (maxSize <= 0) { 98 | throw new IllegalArgumentException("maxSize <= 0"); 99 | } 100 | if (valueCount <= 0) { 101 | throw new IllegalArgumentException("valueCount <= 0"); 102 | } 103 | 104 | // prefer to pick up where we left off 105 | DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); 106 | if (cache.journalFile.exists()) { 107 | try { 108 | cache.readJournal(); 109 | cache.processJournal(); 110 | cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true), 111 | IO_BUFFER_SIZE); 112 | return cache; 113 | } catch (IOException journalIsCorrupt) { 114 | System.logW("DiskLruCache " + directory + " is corrupt: " 115 | + journalIsCorrupt.getMessage() + ", removing"); 116 | cache.delete(); 117 | } 118 | } 119 | 120 | // create a new empty cache 121 | directory.mkdirs(); 122 | cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); 123 | cache.rebuildJournal(); 124 | return cache; 125 | } 126 | ``` 127 | 128 | 如果日志文件存在,那就根据日志文件重建条目索引,否则视为第一次在指定目录使用 `DiskLruCache`,执行初始化工作。 129 | 130 | 初始化工作结束后,日志文件的流会一直保持打开的状态,直到我们显式调用 `DiskLruCache#close()` 方法。 131 | 132 | ## 缓存条目的创建与编辑 133 | 通过 `DiskLruCache#edit(String)` 方法可以编辑一个条目(如果条目不存在就先创建一个),这个方法会返回一个 `DiskLruCache$Editor` 对象作为条目操作的句柄,所有文件操作都要通过这个对象来完成。具体实现如下: 134 | 135 | ```java 136 | public Editor edit(String key) throws IOException { 137 | return edit(key, ANY_SEQUENCE_NUMBER); 138 | } 139 | 140 | private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { 141 | checkNotClosed(); 142 | validateKey(key); 143 | Entry entry = lruEntries.get(key); 144 | if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER 145 | && (entry == null || entry.sequenceNumber != expectedSequenceNumber)) { 146 | return null; // snapshot is stale 147 | } 148 | if (entry == null) { 149 | entry = new Entry(key); 150 | lruEntries.put(key, entry); 151 | } else if (entry.currentEditor != null) { 152 | return null; // another edit is in progress 153 | } 154 | 155 | Editor editor = new Editor(entry); 156 | entry.currentEditor = editor; 157 | 158 | // flush the journal before creating files to prevent file leaks 159 | journalWriter.write(DIRTY + ' ' + key + '\n'); 160 | journalWriter.flush(); 161 | return editor; 162 | } 163 | ``` 164 | 165 | 这里实际的逻辑是 `edit(String, long)` 这个重载,其中 `expectedSequenceNumber` 这个参数比较重要,它是 **Snapshot** 机制实现的核心,这个我在下面介绍 `Snapshot` 类的时候再详细阐述。从源码中可以看到,每次执行编辑操作,都会在日志文件中产生一条 `DIRTY` 记录,它可以保证记录被正常 **commit** 了,没有被 **commit** 的编辑操作不会生效,且之前的文件内容也会被视为“已经无效”从而在清理过程中被删除。 166 | 167 | `Editor` 对象提供了几个基本方法来操作一个缓存条目: 168 | 169 | * `newInputStream(int)`: 获取此条目的一个输入流,它会创建一个基于 **clean file** 的一个 `FileInputStream`,因此没有被 **commit** 的脏数据不会被读取。通常,读取缓存数据时我们不使用这个方法。 170 | * `newOutputStream(int)`: 获取此条目的一个输出流,类上上面的方法,只不过它基于的是一个临时文件(**dirty file**),只有被 **commit** 后才会变为 **clean file**。另外值得注意的是,它返回的并非 `FileOutputStream`,而是 `FaultHidingOutputStream`(`FilterOutputStream` 派生类),它会 suppress 并记录所有 IO 错误,在 **commit** 时如果发现之前有 IO 错误发生,则会自动 **abort** 掉此次编辑操作,以保证原有数据不会损坏。 171 | * `commit()` 和 `abort()`: 结束编辑操作,前者是提交更改,后者为放弃编辑的内容,还原之前的状态。 172 | 173 | 我们主要看一下结束编辑状态的实现逻辑,它由 `DiskLruCache#completeEdit(Editor, boolean)` (由 `commit()` 和 `abort()` 调用)实现: 174 | 175 | ```java 176 | private synchronized void completeEdit(Editor editor, boolean success) throws IOException { 177 | Entry entry = editor.entry; 178 | if (entry.currentEditor != editor) { 179 | throw new IllegalStateException(); 180 | } 181 | 182 | // 如果此条目是第一次创建的,需要保证它的每个 index 都有实际文件可以读取, 183 | // 否则抛出异常。 184 | // readable 成员变量表示了这个条目是否已经被成功提交过。 185 | if (success && !entry.readable) { 186 | for (int i = 0; i < valueCount; i++) { 187 | if (!entry.getDirtyFile(i).exists()) { 188 | editor.abort(); 189 | throw new IllegalStateException("edit didn't create file " + i); 190 | } 191 | } 192 | } 193 | 194 | // 根据成功与否来处理条目的所有 index,对于失败的编辑操作,我们删掉其 195 | // dirty file,对于成功提交的条目,我们需要计算它各个 index 最新的 196 | // 文件长度。 197 | for (int i = 0; i < valueCount; i++) { 198 | File dirty = entry.getDirtyFile(i); 199 | if (success) { 200 | if (dirty.exists()) { 201 | File clean = entry.getCleanFile(i); 202 | dirty.renameTo(clean); 203 | long oldLength = entry.lengths[i]; 204 | long newLength = clean.length(); 205 | entry.lengths[i] = newLength; 206 | size = size - oldLength + newLength; 207 | } 208 | } else { 209 | deleteIfExists(dirty); 210 | } 211 | } 212 | 213 | // 记录操作次数。 214 | redundantOpCount++; 215 | entry.currentEditor = null; 216 | if (entry.readable | success) { 217 | // 如果条目已经被创建而此次编辑失败了,我们需要写入一条 CLEAN 218 | // 记录,因为要平衡之前的 DIRTY 记录,否则条目就无效了。而对 219 | // 于成功提交的编辑,我们也肯定是要写入 CLEAN 记录的。 220 | entry.readable = true; 221 | journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); 222 | if (success) { 223 | // 成功提交,自增版本号,原有的 Snapshot 对象将无效。 224 | entry.sequenceNumber = nextSequenceNumber++; 225 | } 226 | } else { 227 | // 对于新创建而又提交失败的条目来说,我们需要将其从链表中删除, 228 | // 此外还要写入一条 REMOVE 记录来平衡 DIRTY 记录。 229 | lruEntries.remove(entry.key); 230 | journalWriter.write(REMOVE + ' ' + entry.key + '\n'); 231 | } 232 | 233 | if (size > maxSize || journalRebuildRequired()) { 234 | executorService.submit(cleanupCallable); 235 | } 236 | } 237 | ``` 238 | 239 | ## 缓存条目的读取与 Snapshot 机制 240 | 读取一个条目需要调用 `DiskLruCache#get(String)` 方法,它返回的是一个 `DiskLruCache$Snapshot` 对象,并且日志文件中会记录此次读取操作。 241 | 242 | 这里就会牵扯到一个概念:Snapshot。顾名思义,它反映了某一个缓存版本(由 `Entry` 的 `sequenceNumber` 成员变量决定),我们可能会在读取某一个条目的同时去修改这个条目,这会导致之前的版本失效,在 `Snapshot` 对象的 `edit()` 方法中,它会调用 `DiskLruCache#edit(String, long)`,这时 `expectedSequenceNumber` 这个参数就会发挥作用了。由于 `Editor` 修改后变更的是 `Entry` 中的版本号,而 `Snapshot` 的版本号不会发生变化,版本号不一致就会让此方法返回 **null**,从而无法再对此版本的 `Snapshot` 进行编辑。 243 | 244 | ## 缓存清理 245 | LRU 的意义就在于能够适时清理掉不必要的数据,这个清理操作在 `DiskLruCache` 中由若干部分组成,其中比较重要的是 `DiskLruCache#trimToSize()` 这个方法,实现如下: 246 | 247 | ```java 248 | private void trimToSize() throws IOException { 249 | while (size > maxSize) { 250 | final Map.Entry toEvict = lruEntries.entrySet().iterator().next(); 251 | remove(toEvict.getKey()); 252 | } 253 | } 254 | ``` 255 | 256 | 由于访问操作已经将节点顺序调整好了,因此这里就可以直接依次 remove 各个条目,直到缓存大小不再超出限制。 257 | 258 | 清理工作的另外一个部分就是重建日志,随着缓存操作的不断进行,日志文件会愈来愈庞大,为了将其稳定在一个合理的大小,我们就需要适时地对日志文件进行重建。重建的条件由这个方法决定: 259 | 260 | ```java 261 | private boolean journalRebuildRequired() { 262 | final int REDUNDANT_OP_COMPACT_THRESHOLD = 2000; 263 | return redundantOpCount >= REDUNDANT_OP_COMPACT_THRESHOLD 264 | && redundantOpCount >= lruEntries.size(); 265 | } 266 | ``` 267 | 268 | 当操作的数量达到一定阈值,且记录数多于条目数时才会触发重建。因为有的时候缓存条目很多,可能超过了清理阈值,但没有冗余记录存在(记录数多于条目数),这时就不需要再重建日志了。 269 | 270 | 而重建日志的逻辑也很简单,依次遍历条目,根据其是否正在编辑来写入 **DIRTY** 或 **CLEAN** 记录就可以了,重建的目的就是为了去除 **READ** 和 **REMOVE** 记录,因为节点顺序已经调整好了,就不需要 **READ** 记录再去重复调整了,而已经删除的条目也不会有对应的 **CLEAN** 记录,因此 **REMOVE** 记录也没有必要存在了。 271 | 272 | 此外 `DiskLruCache` 还会有一个线程来执行清理工作,很多会写入日志的操作结束时都会让这个线程执行一次清理操作,以保证日志的简洁。 273 | 274 | ## 小结 275 | 到这里 `DiskLruCache` 的核心实现就介绍完了,其实还是比较简单的。这类工具类的实现不难但是很精巧,很多 Edge Cases 都要考虑到,还有多线程的同步问题,日志文件的设计等等,本文也仅仅是一个源码导读,更多的细节还是要大家从源码中去学习。 276 | 277 | ## 推广信息 278 | 如果你对我的 Android 源码分析系列文章感兴趣,可以点个 star 哦,我会持续不定期更新文章。 -------------------------------------------------------------------------------- /DiskLruCache/sources/com/cyandev/DiskLruCache.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.cyandev; 18 | 19 | import java.io.BufferedInputStream; 20 | import java.io.BufferedWriter; 21 | import java.io.Closeable; 22 | import java.io.EOFException; 23 | import java.io.File; 24 | import java.io.FileInputStream; 25 | import java.io.FileNotFoundException; 26 | import java.io.FileOutputStream; 27 | import java.io.FileWriter; 28 | import java.io.FilterOutputStream; 29 | import java.io.IOException; 30 | import java.io.InputStream; 31 | import java.io.InputStreamReader; 32 | import java.io.OutputStream; 33 | import java.io.OutputStreamWriter; 34 | import java.io.Reader; 35 | import java.io.StringWriter; 36 | import java.io.Writer; 37 | import java.lang.reflect.Array; 38 | import java.nio.charset.Charset; 39 | import java.util.ArrayList; 40 | import java.util.Arrays; 41 | import java.util.Iterator; 42 | import java.util.LinkedHashMap; 43 | import java.util.Map; 44 | import java.util.concurrent.Callable; 45 | import java.util.concurrent.ExecutorService; 46 | import java.util.concurrent.LinkedBlockingQueue; 47 | import java.util.concurrent.ThreadPoolExecutor; 48 | import java.util.concurrent.TimeUnit; 49 | 50 | /** 51 | ****************************************************************************** 52 | * Taken from the JB source code, can be found in: 53 | * libcore/luni/src/main/java/libcore/io/DiskLruCache.java 54 | * or direct link: 55 | * https://android.googlesource.com/platform/libcore/+/android-4.1.1_r1/luni/src/main/java/libcore/io/DiskLruCache.java 56 | ****************************************************************************** 57 | * 58 | * A cache that uses a bounded amount of space on a filesystem. Each cache 59 | * entry has a string key and a fixed number of values. Values are byte 60 | * sequences, accessible as streams or files. Each value must be between {@code 61 | * 0} and {@code Integer.MAX_VALUE} bytes in length. 62 | * 63 | *

The cache stores its data in a directory on the filesystem. This 64 | * directory must be exclusive to the cache; the cache may delete or overwrite 65 | * files from its directory. It is an error for multiple processes to use the 66 | * same cache directory at the same time. 67 | * 68 | *

This cache limits the number of bytes that it will store on the 69 | * filesystem. When the number of stored bytes exceeds the limit, the cache will 70 | * remove entries in the background until the limit is satisfied. The limit is 71 | * not strict: the cache may temporarily exceed it while waiting for files to be 72 | * deleted. The limit does not include filesystem overhead or the cache 73 | * journal so space-sensitive applications should set a conservative limit. 74 | * 75 | *

Clients call {@link #edit} to create or update the values of an entry. An 76 | * entry may have only one editor at one time; if a value is not available to be 77 | * edited then {@link #edit} will return null. 78 | *

    79 | *
  • When an entry is being created it is necessary to 80 | * supply a full set of values; the empty value should be used as a 81 | * placeholder if necessary. 82 | *
  • When an entry is being edited, it is not necessary 83 | * to supply data for every value; values default to their previous 84 | * value. 85 | *
86 | * Every {@link #edit} call must be matched by a call to {@link Editor#commit} 87 | * or {@link Editor#abort}. Committing is atomic: a read observes the full set 88 | * of values as they were before or after the commit, but never a mix of values. 89 | * 90 | *

Clients call {@link #get} to read a snapshot of an entry. The read will 91 | * observe the value at the time that {@link #get} was called. Updates and 92 | * removals after the call do not impact ongoing reads. 93 | * 94 | *

This class is tolerant of some I/O errors. If files are missing from the 95 | * filesystem, the corresponding entries will be dropped from the cache. If 96 | * an error occurs while writing a cache value, the edit will fail silently. 97 | * Callers should handle other problems by catching {@code IOException} and 98 | * responding appropriately. 99 | */ 100 | public final class DiskLruCache implements Closeable { 101 | static final String JOURNAL_FILE = "journal"; 102 | static final String JOURNAL_FILE_TMP = "journal.tmp"; 103 | static final String MAGIC = "libcore.io.DiskLruCache"; 104 | static final String VERSION_1 = "1"; 105 | static final long ANY_SEQUENCE_NUMBER = -1; 106 | private static final String CLEAN = "CLEAN"; 107 | private static final String DIRTY = "DIRTY"; 108 | private static final String REMOVE = "REMOVE"; 109 | private static final String READ = "READ"; 110 | 111 | private static final Charset UTF_8 = Charset.forName("UTF-8"); 112 | private static final int IO_BUFFER_SIZE = 8 * 1024; 113 | 114 | /* 115 | * This cache uses a journal file named "journal". A typical journal file 116 | * looks like this: 117 | * libcore.io.DiskLruCache 118 | * 1 119 | * 100 120 | * 2 121 | * 122 | * CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 123 | * DIRTY 335c4c6028171cfddfbaae1a9c313c52 124 | * CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342 125 | * REMOVE 335c4c6028171cfddfbaae1a9c313c52 126 | * DIRTY 1ab96a171faeeee38496d8b330771a7a 127 | * CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234 128 | * READ 335c4c6028171cfddfbaae1a9c313c52 129 | * READ 3400330d1dfc7f3f7f4b8d4d803dfcf6 130 | * 131 | * The first five lines of the journal form its header. They are the 132 | * constant string "libcore.io.DiskLruCache", the disk cache's version, 133 | * the application's version, the value count, and a blank line. 134 | * 135 | * Each of the subsequent lines in the file is a record of the state of a 136 | * cache entry. Each line contains space-separated values: a state, a key, 137 | * and optional state-specific values. 138 | * o DIRTY lines track that an entry is actively being created or updated. 139 | * Every successful DIRTY action should be followed by a CLEAN or REMOVE 140 | * action. DIRTY lines without a matching CLEAN or REMOVE indicate that 141 | * temporary files may need to be deleted. 142 | * o CLEAN lines track a cache entry that has been successfully published 143 | * and may be read. A publish line is followed by the lengths of each of 144 | * its values. 145 | * o READ lines track accesses for LRU. 146 | * o REMOVE lines track entries that have been deleted. 147 | * 148 | * The journal file is appended to as cache operations occur. The journal may 149 | * occasionally be compacted by dropping redundant lines. A temporary file named 150 | * "journal.tmp" will be used during compaction; that file should be deleted if 151 | * it exists when the cache is opened. 152 | */ 153 | 154 | private final File directory; 155 | private final File journalFile; 156 | private final File journalFileTmp; 157 | private final int appVersion; 158 | private final long maxSize; 159 | private final int valueCount; 160 | private long size = 0; 161 | private Writer journalWriter; 162 | private final LinkedHashMap lruEntries 163 | = new LinkedHashMap(0, 0.75f, true); 164 | private int redundantOpCount; 165 | 166 | /** 167 | * To differentiate between old and current snapshots, each entry is given 168 | * a sequence number each time an edit is committed. A snapshot is stale if 169 | * its sequence number is not equal to its entry's sequence number. 170 | */ 171 | private long nextSequenceNumber = 0; 172 | 173 | /* From java.util.Arrays */ 174 | @SuppressWarnings("unchecked") 175 | private static T[] copyOfRange(T[] original, int start, int end) { 176 | final int originalLength = original.length; // For exception priority compatibility. 177 | if (start > end) { 178 | throw new IllegalArgumentException(); 179 | } 180 | if (start < 0 || start > originalLength) { 181 | throw new ArrayIndexOutOfBoundsException(); 182 | } 183 | final int resultLength = end - start; 184 | final int copyLength = Math.min(resultLength, originalLength - start); 185 | final T[] result = (T[]) Array 186 | .newInstance(original.getClass().getComponentType(), resultLength); 187 | System.arraycopy(original, start, result, 0, copyLength); 188 | return result; 189 | } 190 | 191 | /** 192 | * Returns the remainder of 'reader' as a string, closing it when done. 193 | */ 194 | public static String readFully(Reader reader) throws IOException { 195 | try { 196 | StringWriter writer = new StringWriter(); 197 | char[] buffer = new char[1024]; 198 | int count; 199 | while ((count = reader.read(buffer)) != -1) { 200 | writer.write(buffer, 0, count); 201 | } 202 | return writer.toString(); 203 | } finally { 204 | reader.close(); 205 | } 206 | } 207 | 208 | /** 209 | * Returns the ASCII characters up to but not including the next "\r\n", or 210 | * "\n". 211 | * 212 | * @throws java.io.EOFException if the stream is exhausted before the next newline 213 | * character. 214 | */ 215 | public static String readAsciiLine(InputStream in) throws IOException { 216 | // TODO: support UTF-8 here instead 217 | 218 | StringBuilder result = new StringBuilder(80); 219 | while (true) { 220 | int c = in.read(); 221 | if (c == -1) { 222 | throw new EOFException(); 223 | } else if (c == '\n') { 224 | break; 225 | } 226 | 227 | result.append((char) c); 228 | } 229 | int length = result.length(); 230 | if (length > 0 && result.charAt(length - 1) == '\r') { 231 | result.setLength(length - 1); 232 | } 233 | return result.toString(); 234 | } 235 | 236 | /** 237 | * Closes 'closeable', ignoring any checked exceptions. Does nothing if 'closeable' is null. 238 | */ 239 | public static void closeQuietly(Closeable closeable) { 240 | if (closeable != null) { 241 | try { 242 | closeable.close(); 243 | } catch (RuntimeException rethrown) { 244 | throw rethrown; 245 | } catch (Exception ignored) { 246 | } 247 | } 248 | } 249 | 250 | /** 251 | * Recursively delete everything in {@code dir}. 252 | */ 253 | // TODO: this should specify paths as Strings rather than as Files 254 | public static void deleteContents(File dir) throws IOException { 255 | File[] files = dir.listFiles(); 256 | if (files == null) { 257 | throw new IllegalArgumentException("not a directory: " + dir); 258 | } 259 | for (File file : files) { 260 | if (file.isDirectory()) { 261 | deleteContents(file); 262 | } 263 | if (!file.delete()) { 264 | throw new IOException("failed to delete file: " + file); 265 | } 266 | } 267 | } 268 | 269 | /** This cache uses a single background thread to evict entries. */ 270 | private final ExecutorService executorService = new ThreadPoolExecutor(0, 1, 271 | 60L, TimeUnit.SECONDS, new LinkedBlockingQueue()); 272 | private final Callable cleanupCallable = new Callable() { 273 | @Override public Void call() throws Exception { 274 | synchronized (DiskLruCache.this) { 275 | if (journalWriter == null) { 276 | return null; // closed 277 | } 278 | trimToSize(); 279 | if (journalRebuildRequired()) { 280 | rebuildJournal(); 281 | redundantOpCount = 0; 282 | } 283 | } 284 | return null; 285 | } 286 | }; 287 | 288 | private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) { 289 | this.directory = directory; 290 | this.appVersion = appVersion; 291 | this.journalFile = new File(directory, JOURNAL_FILE); 292 | this.journalFileTmp = new File(directory, JOURNAL_FILE_TMP); 293 | this.valueCount = valueCount; 294 | this.maxSize = maxSize; 295 | } 296 | 297 | /** 298 | * Opens the cache in {@code directory}, creating a cache if none exists 299 | * there. 300 | * 301 | * @param directory a writable directory 302 | * @param appVersion 303 | * @param valueCount the number of values per cache entry. Must be positive. 304 | * @param maxSize the maximum number of bytes this cache should use to store 305 | * @throws java.io.IOException if reading or writing the cache directory fails 306 | */ 307 | public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) 308 | throws IOException { 309 | if (maxSize <= 0) { 310 | throw new IllegalArgumentException("maxSize <= 0"); 311 | } 312 | if (valueCount <= 0) { 313 | throw new IllegalArgumentException("valueCount <= 0"); 314 | } 315 | 316 | // prefer to pick up where we left off 317 | DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); 318 | if (cache.journalFile.exists()) { 319 | try { 320 | cache.readJournal(); 321 | cache.processJournal(); 322 | cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true), 323 | IO_BUFFER_SIZE); 324 | return cache; 325 | } catch (IOException journalIsCorrupt) { 326 | // System.logW("DiskLruCache " + directory + " is corrupt: " 327 | // + journalIsCorrupt.getMessage() + ", removing"); 328 | cache.delete(); 329 | } 330 | } 331 | 332 | // create a new empty cache 333 | directory.mkdirs(); 334 | cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); 335 | cache.rebuildJournal(); 336 | return cache; 337 | } 338 | 339 | private void readJournal() throws IOException { 340 | InputStream in = new BufferedInputStream(new FileInputStream(journalFile), IO_BUFFER_SIZE); 341 | try { 342 | String magic = readAsciiLine(in); 343 | String version = readAsciiLine(in); 344 | String appVersionString = readAsciiLine(in); 345 | String valueCountString = readAsciiLine(in); 346 | String blank = readAsciiLine(in); 347 | if (!MAGIC.equals(magic) 348 | || !VERSION_1.equals(version) 349 | || !Integer.toString(appVersion).equals(appVersionString) 350 | || !Integer.toString(valueCount).equals(valueCountString) 351 | || !"".equals(blank)) { 352 | throw new IOException("unexpected journal header: [" 353 | + magic + ", " + version + ", " + valueCountString + ", " + blank + "]"); 354 | } 355 | 356 | while (true) { 357 | try { 358 | readJournalLine(readAsciiLine(in)); 359 | } catch (EOFException endOfJournal) { 360 | break; 361 | } 362 | } 363 | } finally { 364 | closeQuietly(in); 365 | } 366 | } 367 | 368 | private void readJournalLine(String line) throws IOException { 369 | String[] parts = line.split(" "); 370 | if (parts.length < 2) { 371 | throw new IOException("unexpected journal line: " + line); 372 | } 373 | 374 | String key = parts[1]; 375 | if (parts[0].equals(REMOVE) && parts.length == 2) { 376 | lruEntries.remove(key); 377 | return; 378 | } 379 | 380 | Entry entry = lruEntries.get(key); 381 | if (entry == null) { 382 | entry = new Entry(key); 383 | lruEntries.put(key, entry); 384 | } 385 | 386 | if (parts[0].equals(CLEAN) && parts.length == 2 + valueCount) { 387 | entry.readable = true; 388 | entry.currentEditor = null; 389 | entry.setLengths(copyOfRange(parts, 2, parts.length)); 390 | } else if (parts[0].equals(DIRTY) && parts.length == 2) { 391 | entry.currentEditor = new Editor(entry); 392 | } else if (parts[0].equals(READ) && parts.length == 2) { 393 | // this work was already done by calling lruEntries.get() 394 | } else { 395 | throw new IOException("unexpected journal line: " + line); 396 | } 397 | } 398 | 399 | /** 400 | * Computes the initial size and collects garbage as a part of opening the 401 | * cache. Dirty entries are assumed to be inconsistent and will be deleted. 402 | */ 403 | private void processJournal() throws IOException { 404 | deleteIfExists(journalFileTmp); 405 | for (Iterator i = lruEntries.values().iterator(); i.hasNext(); ) { 406 | Entry entry = i.next(); 407 | if (entry.currentEditor == null) { 408 | for (int t = 0; t < valueCount; t++) { 409 | size += entry.lengths[t]; 410 | } 411 | } else { 412 | entry.currentEditor = null; 413 | for (int t = 0; t < valueCount; t++) { 414 | deleteIfExists(entry.getCleanFile(t)); 415 | deleteIfExists(entry.getDirtyFile(t)); 416 | } 417 | i.remove(); 418 | } 419 | } 420 | } 421 | 422 | /** 423 | * Creates a new journal that omits redundant information. This replaces the 424 | * current journal if it exists. 425 | */ 426 | private synchronized void rebuildJournal() throws IOException { 427 | if (journalWriter != null) { 428 | journalWriter.close(); 429 | } 430 | 431 | Writer writer = new BufferedWriter(new FileWriter(journalFileTmp), IO_BUFFER_SIZE); 432 | writer.write(MAGIC); 433 | writer.write("\n"); 434 | writer.write(VERSION_1); 435 | writer.write("\n"); 436 | writer.write(Integer.toString(appVersion)); 437 | writer.write("\n"); 438 | writer.write(Integer.toString(valueCount)); 439 | writer.write("\n"); 440 | writer.write("\n"); 441 | 442 | for (Entry entry : lruEntries.values()) { 443 | if (entry.currentEditor != null) { 444 | writer.write(DIRTY + ' ' + entry.key + '\n'); 445 | } else { 446 | writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); 447 | } 448 | } 449 | 450 | writer.close(); 451 | journalFileTmp.renameTo(journalFile); 452 | journalWriter = new BufferedWriter(new FileWriter(journalFile, true), IO_BUFFER_SIZE); 453 | } 454 | 455 | private static void deleteIfExists(File file) throws IOException { 456 | // try { 457 | // Libcore.os.remove(file.getPath()); 458 | // } catch (ErrnoException errnoException) { 459 | // if (errnoException.errno != OsConstants.ENOENT) { 460 | // throw errnoException.rethrowAsIOException(); 461 | // } 462 | // } 463 | if (file.exists() && !file.delete()) { 464 | throw new IOException(); 465 | } 466 | } 467 | 468 | /** 469 | * Returns a snapshot of the entry named {@code key}, or null if it doesn't 470 | * exist is not currently readable. If a value is returned, it is moved to 471 | * the head of the LRU queue. 472 | */ 473 | public synchronized Snapshot get(String key) throws IOException { 474 | checkNotClosed(); 475 | validateKey(key); 476 | Entry entry = lruEntries.get(key); 477 | if (entry == null) { 478 | return null; 479 | } 480 | 481 | if (!entry.readable) { 482 | return null; 483 | } 484 | 485 | /* 486 | * Open all streams eagerly to guarantee that we see a single published 487 | * snapshot. If we opened streams lazily then the streams could come 488 | * from different edits. 489 | */ 490 | InputStream[] ins = new InputStream[valueCount]; 491 | try { 492 | for (int i = 0; i < valueCount; i++) { 493 | ins[i] = new FileInputStream(entry.getCleanFile(i)); 494 | } 495 | } catch (FileNotFoundException e) { 496 | // a file must have been deleted manually! 497 | return null; 498 | } 499 | 500 | redundantOpCount++; 501 | journalWriter.append(READ + ' ' + key + '\n'); 502 | if (journalRebuildRequired()) { 503 | executorService.submit(cleanupCallable); 504 | } 505 | 506 | return new Snapshot(key, entry.sequenceNumber, ins); 507 | } 508 | 509 | /** 510 | * Returns an editor for the entry named {@code key}, or null if another 511 | * edit is in progress. 512 | */ 513 | public Editor edit(String key) throws IOException { 514 | return edit(key, ANY_SEQUENCE_NUMBER); 515 | } 516 | 517 | private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { 518 | checkNotClosed(); 519 | validateKey(key); 520 | Entry entry = lruEntries.get(key); 521 | if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER 522 | && (entry == null || entry.sequenceNumber != expectedSequenceNumber)) { 523 | return null; // snapshot is stale 524 | } 525 | if (entry == null) { 526 | entry = new Entry(key); 527 | lruEntries.put(key, entry); 528 | } else if (entry.currentEditor != null) { 529 | return null; // another edit is in progress 530 | } 531 | 532 | Editor editor = new Editor(entry); 533 | entry.currentEditor = editor; 534 | 535 | // flush the journal before creating files to prevent file leaks 536 | journalWriter.write(DIRTY + ' ' + key + '\n'); 537 | journalWriter.flush(); 538 | return editor; 539 | } 540 | 541 | /** 542 | * Returns the directory where this cache stores its data. 543 | */ 544 | public File getDirectory() { 545 | return directory; 546 | } 547 | 548 | /** 549 | * Returns the maximum number of bytes that this cache should use to store 550 | * its data. 551 | */ 552 | public long maxSize() { 553 | return maxSize; 554 | } 555 | 556 | /** 557 | * Returns the number of bytes currently being used to store the values in 558 | * this cache. This may be greater than the max size if a background 559 | * deletion is pending. 560 | */ 561 | public synchronized long size() { 562 | return size; 563 | } 564 | 565 | private synchronized void completeEdit(Editor editor, boolean success) throws IOException { 566 | Entry entry = editor.entry; 567 | if (entry.currentEditor != editor) { 568 | throw new IllegalStateException(); 569 | } 570 | 571 | // if this edit is creating the entry for the first time, every index must have a value 572 | if (success && !entry.readable) { 573 | for (int i = 0; i < valueCount; i++) { 574 | if (!entry.getDirtyFile(i).exists()) { 575 | editor.abort(); 576 | throw new IllegalStateException("edit didn't create file " + i); 577 | } 578 | } 579 | } 580 | 581 | for (int i = 0; i < valueCount; i++) { 582 | File dirty = entry.getDirtyFile(i); 583 | if (success) { 584 | if (dirty.exists()) { 585 | File clean = entry.getCleanFile(i); 586 | dirty.renameTo(clean); 587 | long oldLength = entry.lengths[i]; 588 | long newLength = clean.length(); 589 | entry.lengths[i] = newLength; 590 | size = size - oldLength + newLength; 591 | } 592 | } else { 593 | deleteIfExists(dirty); 594 | } 595 | } 596 | 597 | redundantOpCount++; 598 | entry.currentEditor = null; 599 | if (entry.readable | success) { 600 | entry.readable = true; 601 | journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); 602 | if (success) { 603 | entry.sequenceNumber = nextSequenceNumber++; 604 | } 605 | } else { 606 | lruEntries.remove(entry.key); 607 | journalWriter.write(REMOVE + ' ' + entry.key + '\n'); 608 | } 609 | 610 | if (size > maxSize || journalRebuildRequired()) { 611 | executorService.submit(cleanupCallable); 612 | } 613 | } 614 | 615 | /** 616 | * We only rebuild the journal when it will halve the size of the journal 617 | * and eliminate at least 2000 ops. 618 | */ 619 | private boolean journalRebuildRequired() { 620 | final int REDUNDANT_OP_COMPACT_THRESHOLD = 2000; 621 | return redundantOpCount >= REDUNDANT_OP_COMPACT_THRESHOLD 622 | && redundantOpCount >= lruEntries.size(); 623 | } 624 | 625 | /** 626 | * Drops the entry for {@code key} if it exists and can be removed. Entries 627 | * actively being edited cannot be removed. 628 | * 629 | * @return true if an entry was removed. 630 | */ 631 | public synchronized boolean remove(String key) throws IOException { 632 | checkNotClosed(); 633 | validateKey(key); 634 | Entry entry = lruEntries.get(key); 635 | if (entry == null || entry.currentEditor != null) { 636 | return false; 637 | } 638 | 639 | for (int i = 0; i < valueCount; i++) { 640 | File file = entry.getCleanFile(i); 641 | if (!file.delete()) { 642 | throw new IOException("failed to delete " + file); 643 | } 644 | size -= entry.lengths[i]; 645 | entry.lengths[i] = 0; 646 | } 647 | 648 | redundantOpCount++; 649 | journalWriter.append(REMOVE + ' ' + key + '\n'); 650 | lruEntries.remove(key); 651 | 652 | if (journalRebuildRequired()) { 653 | executorService.submit(cleanupCallable); 654 | } 655 | 656 | return true; 657 | } 658 | 659 | /** 660 | * Returns true if this cache has been closed. 661 | */ 662 | public boolean isClosed() { 663 | return journalWriter == null; 664 | } 665 | 666 | private void checkNotClosed() { 667 | if (journalWriter == null) { 668 | throw new IllegalStateException("cache is closed"); 669 | } 670 | } 671 | 672 | /** 673 | * Force buffered operations to the filesystem. 674 | */ 675 | public synchronized void flush() throws IOException { 676 | checkNotClosed(); 677 | trimToSize(); 678 | journalWriter.flush(); 679 | } 680 | 681 | /** 682 | * Closes this cache. Stored values will remain on the filesystem. 683 | */ 684 | public synchronized void close() throws IOException { 685 | if (journalWriter == null) { 686 | return; // already closed 687 | } 688 | for (Entry entry : new ArrayList(lruEntries.values())) { 689 | if (entry.currentEditor != null) { 690 | entry.currentEditor.abort(); 691 | } 692 | } 693 | trimToSize(); 694 | journalWriter.close(); 695 | journalWriter = null; 696 | } 697 | 698 | private void trimToSize() throws IOException { 699 | while (size > maxSize) { 700 | // Map.Entry toEvict = lruEntries.eldest(); 701 | final Map.Entry toEvict = lruEntries.entrySet().iterator().next(); 702 | remove(toEvict.getKey()); 703 | } 704 | } 705 | 706 | /** 707 | * Closes the cache and deletes all of its stored values. This will delete 708 | * all files in the cache directory including files that weren't created by 709 | * the cache. 710 | */ 711 | public void delete() throws IOException { 712 | close(); 713 | deleteContents(directory); 714 | } 715 | 716 | private void validateKey(String key) { 717 | if (key.contains(" ") || key.contains("\n") || key.contains("\r")) { 718 | throw new IllegalArgumentException( 719 | "keys must not contain spaces or newlines: \"" + key + "\""); 720 | } 721 | } 722 | 723 | private static String inputStreamToString(InputStream in) throws IOException { 724 | return readFully(new InputStreamReader(in, UTF_8)); 725 | } 726 | 727 | /** 728 | * A snapshot of the values for an entry. 729 | */ 730 | public final class Snapshot implements Closeable { 731 | private final String key; 732 | private final long sequenceNumber; 733 | private final InputStream[] ins; 734 | 735 | private Snapshot(String key, long sequenceNumber, InputStream[] ins) { 736 | this.key = key; 737 | this.sequenceNumber = sequenceNumber; 738 | this.ins = ins; 739 | } 740 | 741 | /** 742 | * Returns an editor for this snapshot's entry, or null if either the 743 | * entry has changed since this snapshot was created or if another edit 744 | * is in progress. 745 | */ 746 | public Editor edit() throws IOException { 747 | return DiskLruCache.this.edit(key, sequenceNumber); 748 | } 749 | 750 | /** 751 | * Returns the unbuffered stream with the value for {@code index}. 752 | */ 753 | public InputStream getInputStream(int index) { 754 | return ins[index]; 755 | } 756 | 757 | /** 758 | * Returns the string value for {@code index}. 759 | */ 760 | public String getString(int index) throws IOException { 761 | return inputStreamToString(getInputStream(index)); 762 | } 763 | 764 | @Override public void close() { 765 | for (InputStream in : ins) { 766 | closeQuietly(in); 767 | } 768 | } 769 | } 770 | 771 | /** 772 | * Edits the values for an entry. 773 | */ 774 | public final class Editor { 775 | private final Entry entry; 776 | private boolean hasErrors; 777 | 778 | private Editor(Entry entry) { 779 | this.entry = entry; 780 | } 781 | 782 | /** 783 | * Returns an unbuffered input stream to read the last committed value, 784 | * or null if no value has been committed. 785 | */ 786 | public InputStream newInputStream(int index) throws IOException { 787 | synchronized (DiskLruCache.this) { 788 | if (entry.currentEditor != this) { 789 | throw new IllegalStateException(); 790 | } 791 | if (!entry.readable) { 792 | return null; 793 | } 794 | return new FileInputStream(entry.getCleanFile(index)); 795 | } 796 | } 797 | 798 | /** 799 | * Returns the last committed value as a string, or null if no value 800 | * has been committed. 801 | */ 802 | public String getString(int index) throws IOException { 803 | InputStream in = newInputStream(index); 804 | return in != null ? inputStreamToString(in) : null; 805 | } 806 | 807 | /** 808 | * Returns a new unbuffered output stream to write the value at 809 | * {@code index}. If the underlying output stream encounters errors 810 | * when writing to the filesystem, this edit will be aborted when 811 | * {@link #commit} is called. The returned output stream does not throw 812 | * IOExceptions. 813 | */ 814 | public OutputStream newOutputStream(int index) throws IOException { 815 | synchronized (DiskLruCache.this) { 816 | if (entry.currentEditor != this) { 817 | throw new IllegalStateException(); 818 | } 819 | return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index))); 820 | } 821 | } 822 | 823 | /** 824 | * Sets the value at {@code index} to {@code value}. 825 | */ 826 | public void set(int index, String value) throws IOException { 827 | Writer writer = null; 828 | try { 829 | writer = new OutputStreamWriter(newOutputStream(index), UTF_8); 830 | writer.write(value); 831 | } finally { 832 | closeQuietly(writer); 833 | } 834 | } 835 | 836 | /** 837 | * Commits this edit so it is visible to readers. This releases the 838 | * edit lock so another edit may be started on the same key. 839 | */ 840 | public void commit() throws IOException { 841 | if (hasErrors) { 842 | completeEdit(this, false); 843 | remove(entry.key); // the previous entry is stale 844 | } else { 845 | completeEdit(this, true); 846 | } 847 | } 848 | 849 | /** 850 | * Aborts this edit. This releases the edit lock so another edit may be 851 | * started on the same key. 852 | */ 853 | public void abort() throws IOException { 854 | completeEdit(this, false); 855 | } 856 | 857 | private class FaultHidingOutputStream extends FilterOutputStream { 858 | private FaultHidingOutputStream(OutputStream out) { 859 | super(out); 860 | } 861 | 862 | @Override public void write(int oneByte) { 863 | try { 864 | out.write(oneByte); 865 | } catch (IOException e) { 866 | hasErrors = true; 867 | } 868 | } 869 | 870 | @Override public void write(byte[] buffer, int offset, int length) { 871 | try { 872 | out.write(buffer, offset, length); 873 | } catch (IOException e) { 874 | hasErrors = true; 875 | } 876 | } 877 | 878 | @Override public void close() { 879 | try { 880 | out.close(); 881 | } catch (IOException e) { 882 | hasErrors = true; 883 | } 884 | } 885 | 886 | @Override public void flush() { 887 | try { 888 | out.flush(); 889 | } catch (IOException e) { 890 | hasErrors = true; 891 | } 892 | } 893 | } 894 | } 895 | 896 | private final class Entry { 897 | private final String key; 898 | 899 | /** Lengths of this entry's files. */ 900 | private final long[] lengths; 901 | 902 | /** True if this entry has ever been published */ 903 | private boolean readable; 904 | 905 | /** The ongoing edit or null if this entry is not being edited. */ 906 | private Editor currentEditor; 907 | 908 | /** The sequence number of the most recently committed edit to this entry. */ 909 | private long sequenceNumber; 910 | 911 | private Entry(String key) { 912 | this.key = key; 913 | this.lengths = new long[valueCount]; 914 | } 915 | 916 | public String getLengths() throws IOException { 917 | StringBuilder result = new StringBuilder(); 918 | for (long size : lengths) { 919 | result.append(' ').append(size); 920 | } 921 | return result.toString(); 922 | } 923 | 924 | /** 925 | * Set lengths using decimal numbers like "10123". 926 | */ 927 | private void setLengths(String[] strings) throws IOException { 928 | if (strings.length != valueCount) { 929 | throw invalidLengths(strings); 930 | } 931 | 932 | try { 933 | for (int i = 0; i < strings.length; i++) { 934 | lengths[i] = Long.parseLong(strings[i]); 935 | } 936 | } catch (NumberFormatException e) { 937 | throw invalidLengths(strings); 938 | } 939 | } 940 | 941 | private IOException invalidLengths(String[] strings) throws IOException { 942 | throw new IOException("unexpected journal line: " + Arrays.toString(strings)); 943 | } 944 | 945 | public File getCleanFile(int i) { 946 | return new File(directory, key + "." + i); 947 | } 948 | 949 | public File getDirtyFile(int i) { 950 | return new File(directory, key + "." + i + ".tmp"); 951 | } 952 | } 953 | } 954 | -------------------------------------------------------------------------------- /DiskLruCache/sources/com/cyandev/test/DiskLruCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.cyandev.test; 2 | 3 | import com.cyandev.DiskLruCache; 4 | import org.junit.*; 5 | import org.junit.rules.TemporaryFolder; 6 | 7 | import java.io.*; 8 | import java.util.logging.Logger; 9 | 10 | import static org.junit.Assert.*; 11 | 12 | /** 13 | * Simple unit test for {@link DiskLruCache}. 14 | */ 15 | public class DiskLruCacheTest { 16 | 17 | @Rule 18 | public final TemporaryFolder mTemporaryFolder = new TemporaryFolder(); 19 | 20 | private static final String CACHE_PATH = "cache"; 21 | 22 | private Logger mLogger = Logger.getGlobal(); 23 | private DiskLruCache mCache; 24 | 25 | @Before 26 | public void initialize() throws Exception { 27 | File cacheDir = mTemporaryFolder.newFolder(CACHE_PATH); 28 | mLogger.info("cacheDir=" + cacheDir); 29 | 30 | mCache = DiskLruCache.open(cacheDir, 1, 1, 6); 31 | } 32 | 33 | @After 34 | public void close() throws Exception { 35 | mCache.close(); 36 | } 37 | 38 | @Test 39 | public void addEntries() throws Exception { 40 | DiskLruCache.Editor editor = mCache.edit("1"); 41 | assertTrue(inflateStream(editor.newOutputStream(0), "Foo")); 42 | editor.commit(); 43 | 44 | editor = mCache.edit("2"); 45 | assertTrue(inflateStream(editor.newOutputStream(0), "Bar")); 46 | editor.commit(); 47 | 48 | editor = mCache.edit("3"); 49 | assertTrue(inflateStream(editor.newOutputStream(0), "Baz")); 50 | editor.abort(); 51 | 52 | // Sleep for 1s to wait cleanup to be done. 53 | Thread.sleep(1000); 54 | 55 | DiskLruCache.Snapshot snapshot = mCache.get("1"); 56 | assertNotNull(snapshot); 57 | assertEquals("Foo", snapshot.getString(0)); 58 | snapshot.close(); 59 | 60 | snapshot = mCache.get("2"); 61 | assertNotNull(snapshot); 62 | assertEquals("Bar", snapshot.getString(0)); 63 | snapshot.close(); 64 | 65 | assertNull(mCache.get("3")); 66 | } 67 | 68 | @Test 69 | public void trim() throws Exception { 70 | DiskLruCache.Editor editor = mCache.edit("1"); 71 | assertTrue(inflateStream(editor.newOutputStream(0), "Foo")); 72 | editor.commit(); 73 | 74 | editor = mCache.edit("2"); 75 | assertTrue(inflateStream(editor.newOutputStream(0), "Bar")); 76 | editor.commit(); 77 | 78 | // Access item "1". 79 | mCache.get("1"); 80 | 81 | // When added something made max size exceeded. 82 | editor = mCache.edit("3"); 83 | assertTrue(inflateStream(editor.newOutputStream(0), "Baz")); 84 | editor.commit(); 85 | 86 | // Sleep for 1s to wait cleanup to be done. 87 | Thread.sleep(1000); 88 | 89 | // Then least recently used item should be removed. 90 | DiskLruCache.Snapshot snapshot = mCache.get("1"); 91 | assertNotNull(snapshot); 92 | snapshot.close(); 93 | 94 | snapshot = mCache.get("2"); 95 | assertNull(snapshot); 96 | 97 | snapshot = mCache.get("3"); 98 | assertNotNull(snapshot); 99 | snapshot.close(); 100 | } 101 | 102 | private boolean inflateStream(OutputStream stream, String content) { 103 | try { 104 | OutputStreamWriter writer = new OutputStreamWriter(stream); 105 | writer.write(content); 106 | writer.flush(); 107 | 108 | stream.close(); 109 | 110 | return true; 111 | } catch (IOException e) { 112 | return false; 113 | } 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /ImageMagicMove/README.md: -------------------------------------------------------------------------------- 1 | # 使用自定义动画实现 ImageView 的神奇移动效果 2 | 3 | > 图片的“神奇移动”效果在很多 app 中都很常见,这个效果说白了就是图片由一个缩略图变成一个全屏显示的完整图片时的动画。 4 | 5 | 这里为了直观,我们先直接上最终的效果图: 6 | 7 | ![Preview](https://github.com/unixzii/android-source-codes/raw/master/ImageMagicMove/assets/demo.gif) 8 | 9 | 这个效果看似很简单,但实现起来也不是非常轻松,为什么这么说呢?我们可以简单分析一下这个动画效果。 10 | 11 | 一般来讲,预览图和全屏图片并不处于同一个 Activity,所以我们需要在启动新的 Activity 之前将原始缩略图的位置和大小传递过去,这一点其实还是很简单的,利用 extras 就能搞定。 12 | 13 | 接下来是位置和大小的变换,我们通常可以用 translate 和 scale 来实现,但这里这个方法显然不可用,大家可以仔细观察上面的效果图,注意动画过程中“小熊猫耳朵”附近的变化,可以发现,整个动画过程中,ImageView 并不是在做等比例的缩放,而是会根据大小动态调整内容的裁剪方式。 14 | 15 | 我们都知道,`ImageView` 的 `scaleType` 属性可以规定图片的缩放方式,这里缩略图我们为了美观性一般都会采用 `centerCrop`,从而使图片填满整个 ImageView,而大图为了完整地查看整个图片,我们一般又都会去用 `fitCenter`。(有关各个 Scale Type 的区别本文不再赘述,请读者提前弄清楚) 16 | 17 | ## 分析 `ImageView` 缩放和裁剪图片的实现 18 | 19 | 要实现 Scale Type 的动画,我们首先要弄明白它是怎么在 `ImageView` 中被实现的。这里涉及了一个比较重要的方法:`ImageView#configureBounds()`,它会在 `ImageView` 尺寸发生变化时被调用,在这个方法中,`ImageView` 主要会去调整 drawable 的 bounds 和 **`mDrawMatrix`**。这个 matrix 非常重要,我们之后的动画效果也要很大程度上依赖于这个它。 20 | 21 | 简单来说,只要 `ImageView` 的 `scaleType` 不是 `FIT_XY`(这种方式采用的是 drawable bounds 实现的),matrix 就会在绘制过程中被使用到。 22 | 23 | 而这里我们用到的两种方式分别是 `CENTER_CROP` 和 `FIT_CENTER`,那我们就看看这两种方式下,matrix 是如何被配置的。 24 | 25 | 首先是 `CENTER_CROP`: 26 | 27 | ```java 28 | ... 29 | } else if (ScaleType.CENTER_CROP == mScaleType) { 30 | mDrawMatrix = mMatrix; 31 | 32 | float scale; 33 | float dx = 0, dy = 0; 34 | 35 | if (dwidth * vheight > vwidth * dheight) { 36 | scale = (float) vheight / (float) dheight; 37 | dx = (vwidth - dwidth * scale) * 0.5f; 38 | } else { 39 | scale = (float) vwidth / (float) dwidth; 40 | dy = (vheight - dheight * scale) * 0.5f; 41 | } 42 | 43 | mDrawMatrix.setScale(scale, scale); 44 | mDrawMatrix.postTranslate(Math.round(dx), Math.round(dy)); 45 | } ... 46 | ``` 47 | 48 | **dwidth * vheight > vwidth * dheight** 49 | 50 | 可以化为: 51 | 52 | **dwidth / vwidth > dheight / vheight** 53 | 54 | 那么这个逻辑就是,如果宽度明显宽的话,就让缩放后的图片高度和 ImageView 一致,这样水平方向上的内容就可以通过平移来裁剪掉一部分,反之... 55 | 56 | 然后是 `FIT_CENTER`: 57 | 58 | ```java 59 | } else { 60 | mTempSrc.set(0, 0, dwidth, dheight); 61 | mTempDst.set(0, 0, vwidth, vheight); 62 | 63 | mDrawMatrix = mMatrix; 64 | mDrawMatrix.setRectToRect(mTempSrc, mTempDst, scaleTypeToScaleToFit(mScaleType)); 65 | } 66 | ``` 67 | 68 | 这个就比较简单了,直接使用的 `Matrix` 的方法。 69 | 70 | 这些 matrix 最终都会被赋值给 `mDrawMatrix`,然后在 `onDraw` 时被应用到 Canvas 上就能实现变换了。同时使用 **Find Usages** 可以发现,有名为 `getImageMatrix` 的公开方法可以拿到这个 matrix。至此 matrix 就分析完毕了,我们只需要拿到起始和结束的 matrix,然后通过 `Animator` 就可以实现动画了。 71 | 72 | ## 在 Animator 中使用自定义属性和类型 73 | 74 | 在这个例子中,我们需要对矩阵进行动画变化,这里就涉及 `Property` 和 `TypeEvaluator` 这两个东西了。前者是给 `ObjectAnimator` 用的属性访问器,由于 `ImageView` 并没有自带一个可以设置 `imageMatrix` 的 `Property`,所以我们要自己实现一个,实现方法也是十分简单: 75 | 76 | ```java 77 | private final static Property IMAGE_MATRIX = 78 | new Property(Matrix.class, "imageMatrix") { 79 | @Override 80 | public void set(ImageView object, Matrix value) { 81 | object.setImageMatrix(value); 82 | } 83 | 84 | @Override 85 | public Matrix get(ImageView object) { 86 | return object.getImageMatrix(); 87 | } 88 | }; 89 | ``` 90 | 91 | 然后由于我们要动画的值类型是 `Matrix`,`Animator` 默认也不能对这种类型进行估值处理。注意和插值器的区别,所谓估值,就是给了起始值、结束值和一个进度,然后计算出当前进度的值是多少。`Animator` 对于简单数值类型可以直接估值,而对于除此以外的 `Object` 派生类型,则需要我们提供估值的方法,就是实现 `TypeEvaluator` 接口。 92 | 93 | 对矩阵的估值也很简单,我们知道对浮点数的估值可以采用以下公式: 94 | 95 | ``` 96 | V = S + (E - S) * P 97 | ``` 98 | 99 | 其中 V 就是我们要计算的值,S、E、P 分别表示起始值、结束值和进度。对于矩阵,直接代入实际就相当于矩阵的加法和数乘运算,不涉及复杂的矩阵计算方式,我们只需要对矩阵的 9 个元分别进行上述计算就可以了。 100 | 101 | 简单看一下实现: 102 | 103 | ```java 104 | private static class MatrixEvaluator implements TypeEvaluator { 105 | private float[] mTmpStartValues = new float[9]; 106 | private float[] mTmpEndValues = new float[9]; 107 | private Matrix mTmpMatrix = new Matrix(); 108 | 109 | @Override 110 | public Matrix evaluate(float fraction, Matrix startValue, Matrix endValue) { 111 | startValue.getValues(mTmpStartValues); 112 | endValue.getValues(mTmpEndValues); 113 | for (int i = 0; i < 9; i++) { 114 | float diff = mTmpEndValues[i] - mTmpStartValues[i]; 115 | mTmpEndValues[i] = mTmpStartValues[i] + (fraction * diff); 116 | } 117 | mTmpMatrix.setValues(mTmpEndValues); 118 | 119 | return mTmpMatrix; 120 | } 121 | } 122 | ``` 123 | 124 | 为了性能,我们在估值器内部缓存一个 matrix,每次把结果都存放于这个 matrix 中返回。 125 | 126 | ## 还没结束... 127 | 128 | 至此,我们已经能实现矩阵的变换了,但还不够,`ImageView` 的位置和大小还没变化呢。关于 View 的位置和大小,Android 与 iOS 最大的一个不同就是 iOS 可以非常方便地设置 `UIView` 的 `frame` 和 `bounds`(对应 `CALayer` 的属性,`CALayer` 是 `UIView` 的实际体现),而 Android 中 View 的位置和大小一般都是由父容器在 `onLayout` 时调用 `View#layout` 方法设置好的。但是我们仍然可以随时调用 `layout` 方法重新设置 `View` 的位置和大小。 129 | 130 | 为什么不能设置 `LayoutParams` 呢?虽然可以实现相同的效果,但是这会使父容器重新执行 Layout Passes,需要重新 measure 和 layout,如果视图层级比较复杂就很产生很大的性能开销,尤其是这个动作是以 60fps 的频率执行的。而 `layout` 方法则可以在“不惊动”父容器的情况下完成对位置和大小的设置(硬件加速的情况下只影响 `RenderNode`,它包含了对 `View` 各个属性的描述)。 131 | 132 | 这里为了方便,我们把 View 的位置和大小称作 bounds。对于 bounds 的变化还有一个需要注意的点就是参考系。 133 | 134 | 一般的 app,缩略图所在的视图层级一般较深(比如可能位于 `RecyclerView` 下的列表项 Layout 下 `LinearLayout`),那此时这个 View 的 bounds 原点和大图 View 的 bounds 原点参考系就不同了。 135 | 136 | 这里我的处理方式是,将两个 View 的 bounds 归到相同的参考系下(用 `View#getLocationInWindow` 化为相对于 Window 的位置),然后再比较两个 bounds 的原点差异,用这个差异去 offset 大图的 bounds,就能得到缩略图在大图参考系下的位置了。感觉说起来比较绕口,直接贴出代码来吧: 137 | 138 | ```java 139 | int[] thumbnailOrigin = new int[2]; 140 | int[] fullOrigin = new int[2]; 141 | mThumbnailImageView.getLocationInWindow(thumbnailOrigin); 142 | mFullImageView.getLocationInWindow(fullOrigin); 143 | 144 | int thumbnailLeft = mFullImageView.getLeft() + (thumbnailOrigin[0] - fullOrigin[0]); 145 | int thumbnailTop = mFullImageView.getTop() + (thumbnailOrigin[1] - fullOrigin[1]); 146 | 147 | Rect thumbnailBounds = new Rect(thumbnailLeft, thumbnailTop, 148 | thumbnailLeft + mThumbnailImageView.getWidth(), 149 | thumbnailTop + mThumbnailImageView.getHeight()); 150 | Rect fullBounds = new Rect(mFullImageView.getLeft(), mFullImageView.getTop(), 151 | mFullImageView.getRight(), mFullImageView.getBottom()); 152 | ``` 153 | 154 | 这里我为了演示方便,两个 ImageView 都在同一个 Activity 下,真实情况中这些数据就需要通过 Intent extras 来传递了。 155 | 156 | 由于 bounds 属性也不存在,`Animator` 默认也不能对 `Rect` 进行估值,所以用上文的方法自己实现相关接口就可以了。 157 | 158 | ## 总结 159 | 160 | 到这里,我们总共用了两个 Animator,一个用来变换 bounds,另一个用来变换 matrix。因为 ImageView 是通过 matrix 来缩放和裁剪内容图片的,直接变换 bounds 或 transform 是不能达到本效果的。 161 | 162 | 另外,如果你想使用 [PhotoView](https://github.com/chrisbanes/PhotoView) 也是没有问题的,因为它也是通过 matrix 实现的,本文仅提供了一个思路,具体实现就看大家的需求了。 163 | 164 | 文章开头效果图的核心代码可以参考:[ImageTransitionActivity](https://github.com/unixzii/android-source-codes/blob/master/ImageMagicMove/demo/ImageTransitionActivity.java) 165 | 166 | ## 推广信息 167 | 168 | 如果你对我的 Android 源码分析系列文章感兴趣,可以点个 star 哦,我会持续不定期更新文章。 -------------------------------------------------------------------------------- /ImageMagicMove/assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixzii/android-source-codes/aa308a5aac2189c03964c93579837dd14c65e0c9/ImageMagicMove/assets/demo.gif -------------------------------------------------------------------------------- /ImageMagicMove/demo/ImageTransitionActivity.java: -------------------------------------------------------------------------------- 1 | package com.example.cyandev.androidplayground; 2 | 3 | import android.animation.Animator; 4 | import android.animation.AnimatorListenerAdapter; 5 | import android.animation.AnimatorSet; 6 | import android.animation.ObjectAnimator; 7 | import android.animation.TypeEvaluator; 8 | import android.graphics.Matrix; 9 | import android.graphics.Rect; 10 | import android.graphics.RectF; 11 | import android.graphics.drawable.Drawable; 12 | import android.os.Bundle; 13 | import android.support.annotation.Nullable; 14 | import android.support.v7.app.AppCompatActivity; 15 | import android.util.Property; 16 | import android.view.View; 17 | import android.view.animation.DecelerateInterpolator; 18 | import android.widget.ImageView; 19 | 20 | public class ImageTransitionActivity extends AppCompatActivity { 21 | 22 | private ImageView mThumbnailImageView; 23 | private ImageView mFullImageView; 24 | 25 | private boolean mFullShown = false; 26 | 27 | private final static Property BOUNDS = 28 | new Property(Rect.class, "bounds") { 29 | @Override 30 | public void set(View object, Rect value) { 31 | object.layout(value.left, value.top, value.right, value.bottom); 32 | } 33 | 34 | @Override 35 | public Rect get(View object) { 36 | return new Rect(object.getLeft(), object.getTop(), 37 | object.getRight(), object.getBottom()); 38 | } 39 | }; 40 | 41 | private final static Property IMAGE_MATRIX = 42 | new Property(Matrix.class, "imageMatrix") { 43 | @Override 44 | public void set(ImageView object, Matrix value) { 45 | object.setImageMatrix(value); 46 | } 47 | 48 | @Override 49 | public Matrix get(ImageView object) { 50 | return object.getImageMatrix(); 51 | } 52 | }; 53 | 54 | private static class RectEvaluator implements TypeEvaluator { 55 | private Rect mTmpRect = new Rect(); 56 | 57 | @Override 58 | public Rect evaluate(float fraction, Rect startValue, Rect endValue) { 59 | mTmpRect.left = 60 | (int) (startValue.left + (endValue.left - startValue.left) * fraction); 61 | mTmpRect.top = 62 | (int) (startValue.top + (endValue.top - startValue.top) * fraction); 63 | mTmpRect.right = 64 | (int) (startValue.right + (endValue.right - startValue.right) * fraction); 65 | mTmpRect.bottom = 66 | (int) (startValue.bottom + (endValue.bottom - startValue.bottom) * fraction); 67 | 68 | return mTmpRect; 69 | } 70 | } 71 | 72 | private static class MatrixEvaluator implements TypeEvaluator { 73 | private float[] mTmpStartValues = new float[9]; 74 | private float[] mTmpEndValues = new float[9]; 75 | private Matrix mTmpMatrix = new Matrix(); 76 | 77 | @Override 78 | public Matrix evaluate(float fraction, Matrix startValue, Matrix endValue) { 79 | startValue.getValues(mTmpStartValues); 80 | endValue.getValues(mTmpEndValues); 81 | for (int i = 0; i < 9; i++) { 82 | float diff = mTmpEndValues[i] - mTmpStartValues[i]; 83 | mTmpEndValues[i] = mTmpStartValues[i] + (fraction * diff); 84 | } 85 | mTmpMatrix.setValues(mTmpEndValues); 86 | 87 | return mTmpMatrix; 88 | } 89 | } 90 | 91 | @Override 92 | protected void onCreate(@Nullable Bundle savedInstanceState) { 93 | super.onCreate(savedInstanceState); 94 | setContentView(R.layout.activity_image_transition); 95 | 96 | mThumbnailImageView = (ImageView) findViewById(R.id.thumbnail_image); 97 | mFullImageView = (ImageView) findViewById(R.id.full_image); 98 | 99 | View.OnClickListener onClickListener = new View.OnClickListener() { 100 | @Override 101 | public void onClick(View v) { 102 | mFullShown = !mFullShown; 103 | createAnimator(mFullShown); 104 | } 105 | }; 106 | 107 | mThumbnailImageView.setOnClickListener(onClickListener); 108 | mFullImageView.setOnClickListener(onClickListener); 109 | } 110 | 111 | private Rect getDrawableIntrinsicBounds(Drawable d) { 112 | Rect rect = new Rect(); 113 | rect.right = d.getIntrinsicWidth(); 114 | rect.bottom = d.getIntrinsicHeight(); 115 | 116 | return rect; 117 | } 118 | 119 | private void createAnimator(final boolean in) { 120 | int[] thumbnailOrigin = new int[2]; 121 | int[] fullOrigin = new int[2]; 122 | mThumbnailImageView.getLocationInWindow(thumbnailOrigin); 123 | mFullImageView.getLocationInWindow(fullOrigin); 124 | 125 | int thumbnailLeft = mFullImageView.getLeft() + (thumbnailOrigin[0] - fullOrigin[0]); 126 | int thumbnailTop = mFullImageView.getTop() + (thumbnailOrigin[1] - fullOrigin[1]); 127 | 128 | Rect thumbnailBounds = new Rect(thumbnailLeft, thumbnailTop, 129 | thumbnailLeft + mThumbnailImageView.getWidth(), 130 | thumbnailTop + mThumbnailImageView.getHeight()); 131 | Rect fullBounds = new Rect(mFullImageView.getLeft(), mFullImageView.getTop(), 132 | mFullImageView.getRight(), mFullImageView.getBottom()); 133 | 134 | Matrix thumbnailMatrix = mThumbnailImageView.getImageMatrix(); 135 | Matrix fullMatrix = new Matrix(); 136 | 137 | fullMatrix.setRectToRect(new RectF(getDrawableIntrinsicBounds(mFullImageView.getDrawable())), 138 | new RectF(0, 0, fullBounds.width(), fullBounds.height()), 139 | Matrix.ScaleToFit.CENTER); 140 | 141 | // Temporarily uses `MATRIX` type, because we want to animate the matrix by ourselves. 142 | mFullImageView.setScaleType(ImageView.ScaleType.MATRIX); 143 | mFullImageView.setImageMatrix(in ? thumbnailMatrix : fullMatrix); 144 | mFullImageView.post(new Runnable() { 145 | @Override 146 | public void run() { 147 | if (in) { 148 | mThumbnailImageView.setVisibility(View.INVISIBLE); 149 | } 150 | mFullImageView.setVisibility(View.VISIBLE); 151 | } 152 | }); 153 | 154 | Animator boundsAnimator = ObjectAnimator.ofObject(mFullImageView, BOUNDS, 155 | new RectEvaluator(), 156 | in ? thumbnailBounds : fullBounds, 157 | in ? fullBounds : thumbnailBounds); 158 | Animator matrixAnimator = ObjectAnimator.ofObject(mFullImageView, IMAGE_MATRIX, 159 | new MatrixEvaluator(), 160 | in ? thumbnailMatrix : fullMatrix, 161 | in ? fullMatrix : thumbnailMatrix); 162 | 163 | final Runnable resetRunnable = new Runnable() { 164 | @Override 165 | public void run() { 166 | if (!in) { 167 | mFullImageView.setVisibility(View.INVISIBLE); 168 | mThumbnailImageView.setVisibility(View.VISIBLE); 169 | } 170 | mFullImageView.requestLayout(); 171 | // Animation is finished, reset the scale type to `FIT_CENTER`, which looks the same 172 | // as the end state. 173 | mFullImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); 174 | } 175 | }; 176 | 177 | AnimatorSet animator = new AnimatorSet(); 178 | animator.playTogether(boundsAnimator, matrixAnimator); 179 | animator.setDuration(800); 180 | animator.setInterpolator(new DecelerateInterpolator(3.f)); 181 | animator.addListener(new AnimatorListenerAdapter() { 182 | @Override 183 | public void onAnimationCancel(Animator animation) { 184 | resetRunnable.run(); 185 | } 186 | 187 | @Override 188 | public void onAnimationEnd(Animator animation) { 189 | resetRunnable.run(); 190 | } 191 | }); 192 | animator.start(); 193 | } 194 | 195 | } 196 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Attribution 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More_considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution 4.0 International Public License 58 | 59 | By exercising the Licensed Rights (defined below), You accept and agree 60 | to be bound by the terms and conditions of this Creative Commons 61 | Attribution 4.0 International Public License ("Public License"). To the 62 | extent this Public License may be interpreted as a contract, You are 63 | granted the Licensed Rights in consideration of Your acceptance of 64 | these terms and conditions, and the Licensor grants You such rights in 65 | consideration of benefits the Licensor receives from making the 66 | Licensed Material available under these terms and conditions. 67 | 68 | 69 | Section 1 -- Definitions. 70 | 71 | a. Adapted Material means material subject to Copyright and Similar 72 | Rights that is derived from or based upon the Licensed Material 73 | and in which the Licensed Material is translated, altered, 74 | arranged, transformed, or otherwise modified in a manner requiring 75 | permission under the Copyright and Similar Rights held by the 76 | Licensor. For purposes of this Public License, where the Licensed 77 | Material is a musical work, performance, or sound recording, 78 | Adapted Material is always produced where the Licensed Material is 79 | synched in timed relation with a moving image. 80 | 81 | b. Adapter's License means the license You apply to Your Copyright 82 | and Similar Rights in Your contributions to Adapted Material in 83 | accordance with the terms and conditions of this Public License. 84 | 85 | c. Copyright and Similar Rights means copyright and/or similar rights 86 | closely related to copyright including, without limitation, 87 | performance, broadcast, sound recording, and Sui Generis Database 88 | Rights, without regard to how the rights are labeled or 89 | categorized. For purposes of this Public License, the rights 90 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 91 | Rights. 92 | 93 | d. Effective Technological Measures means those measures that, in the 94 | absence of proper authority, may not be circumvented under laws 95 | fulfilling obligations under Article 11 of the WIPO Copyright 96 | Treaty adopted on December 20, 1996, and/or similar international 97 | agreements. 98 | 99 | e. Exceptions and Limitations means fair use, fair dealing, and/or 100 | any other exception or limitation to Copyright and Similar Rights 101 | that applies to Your use of the Licensed Material. 102 | 103 | f. Licensed Material means the artistic or literary work, database, 104 | or other material to which the Licensor applied this Public 105 | License. 106 | 107 | g. Licensed Rights means the rights granted to You subject to the 108 | terms and conditions of this Public License, which are limited to 109 | all Copyright and Similar Rights that apply to Your use of the 110 | Licensed Material and that the Licensor has authority to license. 111 | 112 | h. Licensor means the individual(s) or entity(ies) granting rights 113 | under this Public License. 114 | 115 | i. Share means to provide material to the public by any means or 116 | process that requires permission under the Licensed Rights, such 117 | as reproduction, public display, public performance, distribution, 118 | dissemination, communication, or importation, and to make material 119 | available to the public including in ways that members of the 120 | public may access the material from a place and at a time 121 | individually chosen by them. 122 | 123 | j. Sui Generis Database Rights means rights other than copyright 124 | resulting from Directive 96/9/EC of the European Parliament and of 125 | the Council of 11 March 1996 on the legal protection of databases, 126 | as amended and/or succeeded, as well as other essentially 127 | equivalent rights anywhere in the world. 128 | 129 | k. You means the individual or entity exercising the Licensed Rights 130 | under this Public License. Your has a corresponding meaning. 131 | 132 | 133 | Section 2 -- Scope. 134 | 135 | a. License grant. 136 | 137 | 1. Subject to the terms and conditions of this Public License, 138 | the Licensor hereby grants You a worldwide, royalty-free, 139 | non-sublicensable, non-exclusive, irrevocable license to 140 | exercise the Licensed Rights in the Licensed Material to: 141 | 142 | a. reproduce and Share the Licensed Material, in whole or 143 | in part; and 144 | 145 | b. produce, reproduce, and Share Adapted Material. 146 | 147 | 2. Exceptions and Limitations. For the avoidance of doubt, where 148 | Exceptions and Limitations apply to Your use, this Public 149 | License does not apply, and You do not need to comply with 150 | its terms and conditions. 151 | 152 | 3. Term. The term of this Public License is specified in Section 153 | 6(a). 154 | 155 | 4. Media and formats; technical modifications allowed. The 156 | Licensor authorizes You to exercise the Licensed Rights in 157 | all media and formats whether now known or hereafter created, 158 | and to make technical modifications necessary to do so. The 159 | Licensor waives and/or agrees not to assert any right or 160 | authority to forbid You from making technical modifications 161 | necessary to exercise the Licensed Rights, including 162 | technical modifications necessary to circumvent Effective 163 | Technological Measures. For purposes of this Public License, 164 | simply making modifications authorized by this Section 2(a) 165 | (4) never produces Adapted Material. 166 | 167 | 5. Downstream recipients. 168 | 169 | a. Offer from the Licensor -- Licensed Material. Every 170 | recipient of the Licensed Material automatically 171 | receives an offer from the Licensor to exercise the 172 | Licensed Rights under the terms and conditions of this 173 | Public License. 174 | 175 | b. No downstream restrictions. You may not offer or impose 176 | any additional or different terms or conditions on, or 177 | apply any Effective Technological Measures to, the 178 | Licensed Material if doing so restricts exercise of the 179 | Licensed Rights by any recipient of the Licensed 180 | Material. 181 | 182 | 6. No endorsement. Nothing in this Public License constitutes or 183 | may be construed as permission to assert or imply that You 184 | are, or that Your use of the Licensed Material is, connected 185 | with, or sponsored, endorsed, or granted official status by, 186 | the Licensor or others designated to receive attribution as 187 | provided in Section 3(a)(1)(A)(i). 188 | 189 | b. Other rights. 190 | 191 | 1. Moral rights, such as the right of integrity, are not 192 | licensed under this Public License, nor are publicity, 193 | privacy, and/or other similar personality rights; however, to 194 | the extent possible, the Licensor waives and/or agrees not to 195 | assert any such rights held by the Licensor to the limited 196 | extent necessary to allow You to exercise the Licensed 197 | Rights, but not otherwise. 198 | 199 | 2. Patent and trademark rights are not licensed under this 200 | Public License. 201 | 202 | 3. To the extent possible, the Licensor waives any right to 203 | collect royalties from You for the exercise of the Licensed 204 | Rights, whether directly or through a collecting society 205 | under any voluntary or waivable statutory or compulsory 206 | licensing scheme. In all other cases the Licensor expressly 207 | reserves any right to collect such royalties. 208 | 209 | 210 | Section 3 -- License Conditions. 211 | 212 | Your exercise of the Licensed Rights is expressly made subject to the 213 | following conditions. 214 | 215 | a. Attribution. 216 | 217 | 1. If You Share the Licensed Material (including in modified 218 | form), You must: 219 | 220 | a. retain the following if it is supplied by the Licensor 221 | with the Licensed Material: 222 | 223 | i. identification of the creator(s) of the Licensed 224 | Material and any others designated to receive 225 | attribution, in any reasonable manner requested by 226 | the Licensor (including by pseudonym if 227 | designated); 228 | 229 | ii. a copyright notice; 230 | 231 | iii. a notice that refers to this Public License; 232 | 233 | iv. a notice that refers to the disclaimer of 234 | warranties; 235 | 236 | v. a URI or hyperlink to the Licensed Material to the 237 | extent reasonably practicable; 238 | 239 | b. indicate if You modified the Licensed Material and 240 | retain an indication of any previous modifications; and 241 | 242 | c. indicate the Licensed Material is licensed under this 243 | Public License, and include the text of, or the URI or 244 | hyperlink to, this Public License. 245 | 246 | 2. You may satisfy the conditions in Section 3(a)(1) in any 247 | reasonable manner based on the medium, means, and context in 248 | which You Share the Licensed Material. For example, it may be 249 | reasonable to satisfy the conditions by providing a URI or 250 | hyperlink to a resource that includes the required 251 | information. 252 | 253 | 3. If requested by the Licensor, You must remove any of the 254 | information required by Section 3(a)(1)(A) to the extent 255 | reasonably practicable. 256 | 257 | 4. If You Share Adapted Material You produce, the Adapter's 258 | License You apply must not prevent recipients of the Adapted 259 | Material from complying with this Public License. 260 | 261 | 262 | Section 4 -- Sui Generis Database Rights. 263 | 264 | Where the Licensed Rights include Sui Generis Database Rights that 265 | apply to Your use of the Licensed Material: 266 | 267 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 268 | to extract, reuse, reproduce, and Share all or a substantial 269 | portion of the contents of the database; 270 | 271 | b. if You include all or a substantial portion of the database 272 | contents in a database in which You have Sui Generis Database 273 | Rights, then the database in which You have Sui Generis Database 274 | Rights (but not its individual contents) is Adapted Material; and 275 | 276 | c. You must comply with the conditions in Section 3(a) if You Share 277 | all or a substantial portion of the contents of the database. 278 | 279 | For the avoidance of doubt, this Section 4 supplements and does not 280 | replace Your obligations under this Public License where the Licensed 281 | Rights include other Copyright and Similar Rights. 282 | 283 | 284 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 285 | 286 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 287 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 288 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 289 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 290 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 291 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 292 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 293 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 294 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 295 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 296 | 297 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 298 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 299 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 300 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 301 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 302 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 303 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 304 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 305 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 306 | 307 | c. The disclaimer of warranties and limitation of liability provided 308 | above shall be interpreted in a manner that, to the extent 309 | possible, most closely approximates an absolute disclaimer and 310 | waiver of all liability. 311 | 312 | 313 | Section 6 -- Term and Termination. 314 | 315 | a. This Public License applies for the term of the Copyright and 316 | Similar Rights licensed here. However, if You fail to comply with 317 | this Public License, then Your rights under this Public License 318 | terminate automatically. 319 | 320 | b. Where Your right to use the Licensed Material has terminated under 321 | Section 6(a), it reinstates: 322 | 323 | 1. automatically as of the date the violation is cured, provided 324 | it is cured within 30 days of Your discovery of the 325 | violation; or 326 | 327 | 2. upon express reinstatement by the Licensor. 328 | 329 | For the avoidance of doubt, this Section 6(b) does not affect any 330 | right the Licensor may have to seek remedies for Your violations 331 | of this Public License. 332 | 333 | c. For the avoidance of doubt, the Licensor may also offer the 334 | Licensed Material under separate terms or conditions or stop 335 | distributing the Licensed Material at any time; however, doing so 336 | will not terminate this Public License. 337 | 338 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 339 | License. 340 | 341 | 342 | Section 7 -- Other Terms and Conditions. 343 | 344 | a. The Licensor shall not be bound by any additional or different 345 | terms or conditions communicated by You unless expressly agreed. 346 | 347 | b. Any arrangements, understandings, or agreements regarding the 348 | Licensed Material not stated herein are separate from and 349 | independent of the terms and conditions of this Public License. 350 | 351 | 352 | Section 8 -- Interpretation. 353 | 354 | a. For the avoidance of doubt, this Public License does not, and 355 | shall not be interpreted to, reduce, limit, restrict, or impose 356 | conditions on any use of the Licensed Material that could lawfully 357 | be made without permission under this Public License. 358 | 359 | b. To the extent possible, if any provision of this Public License is 360 | deemed unenforceable, it shall be automatically reformed to the 361 | minimum extent necessary to make it enforceable. If the provision 362 | cannot be reformed, it shall be severed from this Public License 363 | without affecting the enforceability of the remaining terms and 364 | conditions. 365 | 366 | c. No term or condition of this Public License will be waived and no 367 | failure to comply consented to unless expressly agreed to by the 368 | Licensor. 369 | 370 | d. Nothing in this Public License constitutes or may be interpreted 371 | as a limitation upon, or waiver of, any privileges and immunities 372 | that apply to the Licensor or You, including from the legal 373 | processes of any jurisdiction or authority. 374 | 375 | 376 | ======================================================================= 377 | 378 | Creative Commons is not a party to its public 379 | licenses. Notwithstanding, Creative Commons may elect to apply one of 380 | its public licenses to material it publishes and in those instances 381 | will be considered the “Licensor.” The text of the Creative Commons 382 | public licenses is dedicated to the public domain under the CC0 Public 383 | Domain Dedication. Except for the limited purpose of indicating that 384 | material is shared under a Creative Commons public license or as 385 | otherwise permitted by the Creative Commons policies published at 386 | creativecommons.org/policies, Creative Commons does not authorize the 387 | use of the trademark "Creative Commons" or any other trademark or logo 388 | of Creative Commons without its prior written consent including, 389 | without limitation, in connection with any unauthorized modifications 390 | to any of its public licenses or any other arrangements, 391 | understandings, or agreements concerning use of licensed material. For 392 | the avoidance of doubt, this paragraph does not form part of the 393 | public licenses. 394 | 395 | Creative Commons may be contacted at creativecommons.org. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A repo maintained by Cyandev 2 | # Android 常见项目源码分析 3 | 4 | **2017 11.11 Update**
5 | **OkHttp 拆轮子系列文章目前处于 WIP 阶段,欢迎大家[试读](https://github.com/unixzii/android-source-codes/tree/okhttp/OkHttp)。** 6 | 7 | ## Table of contents 8 | ### 源码分析 9 | * [**SwipeRefreshLayout 源码分析**](https://github.com/unixzii/android-source-codes/tree/master/SwipeRefreshLayout) (更新于 6.16 17:06) 10 | * [**深入理解 fitsSystemWindows**](https://github.com/unixzii/android-source-codes/tree/master/UnderstandingFitsSystemWindows) (更新于 6.18 23:23) 11 | * [**DiskLruCache 实现分析**](https://github.com/unixzii/android-source-codes/tree/master/DiskLruCache) (更新于 9.14 14:10) 12 | 13 | ### Tips & Tricks 14 | * [**使用自定义动画实现 ImageView 的神奇移动效果**](https://github.com/unixzii/android-source-codes/tree/master/ImageMagicMove) (更新于 10.21 17:31) 15 | 16 | ## Contributing 17 | 本项目欢迎大家投稿,撰写格式请参考之前的文章。提交 PR 时请不要修改本 README 中的目录,谢谢合作。 18 | 19 | ## License 20 | 所有文章均采用 [CC-BY-4.0](https://github.com/unixzii/android-source-codes/blob/master/LICENSE) 许可协议。 -------------------------------------------------------------------------------- /SwipeRefreshLayout/README.md: -------------------------------------------------------------------------------- 1 | # SwipeRefreshLayout 源码分析 2 | 3 | > `SwipeRefreshLayout` 是 Android Support 包中的一个控件,旨在为用户提供通用的下拉刷新体验。 4 | 5 | 对于这个控件的功能和外观这里就不做过多的赘述了,我想大家肯定都用过。所以本文就直接切入正题来分析整个控件的实现了。 6 | 7 | 整篇文章我打算分为两部分: 8 | 1. **Nested Scrolling** 机制的运用 9 | 2. 动画效果的实现分析 10 | 11 | 为了大家参考方便,我将组件的核心类 [SwipeRefreshLayout.java](https://github.com/unixzii/android-source-codes/blob/master/SwipeRefreshLayout/SwipeRefreshLayout.java) 也一并提交了上来。 12 | 13 | ## Nested Scrolling 机制的运用 14 | 15 | **Nested Scrolling** 是于 API level 21 (Android 5.0 Lollipop) 加入的一个新特性,在 support 包中也有相关的兼容性实现,顾名思义,它的作用就是处理一些复杂的嵌套滚动。在用户界面中,我们通常会遇到两种嵌套滚动:一种是不同轴的,例如 `ViewPager` 中嵌套 `RecyclerView`,它的处理方式相对简单,就是 `onInterceptTouchEvent` 的应用;另一种就是同轴的,就是本文要着重介绍的 Nested Scrolling。 16 | 17 | Nested Scrolling 主要由两个接口和两个 Helper 类来实现。对于 Nested Scrolling 中各个类的实现分析我不打算在这篇文章中展开了,日后会再开一篇文章来讲述(*未来文章的 Placeholder*)。 18 | 19 | `SwipeRefreshLayout` 实现了两个接口:`NestedScrollingParent`、`NestedScrollingChild`,也就是说,这个控件既可以作为一个嵌套滚动容器的子视图,也可以作为嵌套滚动的容器。一般来讲,我们都会把 `RecyclerView` 塞进它里面,这样就能轻松实现下拉刷新了。然而有的时候,我们需要再将 `SwipeRefreshLayout` 塞入一个 `CoordinatorLayout` 中来实现一些更复杂的效果,由于 `CoordinatorLayout` 也是依靠 Nested Scrolling 实现的,那他就要求子视图实现 `NestedScrollingChild` 才能接收到它的滚动事件,以便于拦截处理。 20 | 21 | 首先看它作为 Parent 部分的实现。 22 | 23 | 总的来说就需要分析下面几个方法: 24 | * `onStartNestedScroll` 25 | * `onNestedScrollAccepted` 26 | * `onNestedPreScroll` 27 | * `onNestedScroll` 28 | * `onStopNestedScroll` 29 | 30 | 首先来看 `onStartNestedScroll`: 31 | ```java 32 | @Override 33 | public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { 34 | return isEnabled() && !mReturningToStart && !mRefreshing 35 | && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; 36 | } 37 | ``` 38 | 39 | 这个方法的调用时机就是当用户刚开始滑动内嵌视图时,嵌套滚动机制会询问这个方法,是否要当做嵌套滚动处理,通常我们在这里判断一下滑动的轴向和其他条件,然后返回一个布尔值表示是否处理。SRL 的实现很简单,判断控件是否启用,返回动画是否未结束,滑动轴向是否为纵向。如果这些条件都满足,那么就对这次滑动全程实施嵌套滚动处理。 40 | 41 | 下面是 `onNestedScrollAccepted`: 42 | ```java 43 | @Override 44 | public void onNestedScrollAccepted(View child, View target, int axes) { 45 | // Reset the counter of how much leftover scroll needs to be consumed. 46 | mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); 47 | // Dispatch up to the nested parent 48 | startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL); 49 | mTotalUnconsumed = 0; 50 | mNestedScrollInProgress = true; 51 | } 52 | ``` 53 | 54 | 这里其实就是初始化了几个状态变量,但注意,`startNestedScroll` 这个方法很有意思,我们跟踪: 55 | ```java 56 | @Override 57 | public boolean startNestedScroll(int axes) { 58 | return mNestedScrollingChildHelper.startNestedScroll(axes); 59 | } 60 | ``` 61 | 62 | 可以看到,由于 SRL 也可以作为嵌套滚动的子视图,所以这个方法的作用就是告知 SRL 的父视图,有一个可以嵌套滚动的子视图开始滚动了,那 SRL 的父视图(可能是 `CoordinatorLayout`)就可以做和 SRL 类似的准备工作了。 63 | 64 | 接下来就是比较重要的几个方法了,首先是 `onNestedPreScroll`,代码篇幅较长,我就把分析写进注释里了: 65 | ```java 66 | @Override 67 | public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { 68 | // 这个方法在子视图滚动之前被调用。 69 | // 这里处理了用户将 Spinner 下拉的半路又上划回去的情况。 70 | if (dy > 0 && mTotalUnconsumed > 0) { 71 | if (dy > mTotalUnconsumed) { 72 | consumed[1] = dy - (int) mTotalUnconsumed; 73 | mTotalUnconsumed = 0; 74 | } else { 75 | mTotalUnconsumed -= dy; 76 | consumed[1] = dy; 77 | } 78 | // 移动 Spinner,下文会分析相关实现。 79 | moveSpinner(mTotalUnconsumed); 80 | } 81 | 82 | // 到这里 consumed 的值可能被修改,如果出现上面的情况,那么这个值就不为 0,表示该滑动已经被 SRL 消费了,子视图你就别再滚动消费的那部分了。 83 | 84 | // 这里处理了用户自定义 Spinner 位置的情况,让它在该出现的位置之前隐藏。 85 | if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0 86 | && Math.abs(dy - consumed[1]) > 0) { 87 | mCircleView.setVisibility(View.GONE); 88 | } 89 | 90 | // 同样的,作为子视图时要通知父视图。 91 | final int[] parentConsumed = mParentScrollConsumed; 92 | if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) { 93 | consumed[0] += parentConsumed[0]; 94 | consumed[1] += parentConsumed[1]; 95 | } 96 | } 97 | ``` 98 | 99 | 然后是 `onNestedScroll`: 100 | ```java 101 | @Override 102 | public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed, 103 | final int dxUnconsumed, final int dyUnconsumed) { 104 | // onNestedPreScroll 调用后子视图会滑动,然后这个方法就会被调用,如果子视图滑动到边界,也会由这几个参数表现出来。 105 | // 先将事件派发给父视图,注意这里有一个很重要的细节! 106 | // 我们将 SRL 放进 CoordinatorLayout 时,CoordinatorLayout 是 107 | // 优先于 SRL 处理滑动越界行为的(比如展开折叠的 Toolbar),最后才 108 | // 轮到 SRL 显示 Spinner,这是怎么做到的呢? 109 | // 秘密就在于下面这个函数的 mParentOffsetInWindow 参数。 110 | dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, 111 | mParentOffsetInWindow); 112 | 113 | // 这里将滚动事件派发给父视图后,父视图一定会有所动作,但是 114 | // 嵌套滚动机制并不知晓父视图做了什么操作,是否消耗了滚动, 115 | // 但是最后一个参数却能传递 SRL 本身在这个事件发生之后 116 | // 的位移,从而推断出父视图消耗的滚动距离。 117 | 118 | // 如果 SRL 滚动了,mParentOffsetInWindow[1] 肯定就不是 0,父视图消耗了滚动,dy 就不是一个负数了。 119 | final int dy = dyUnconsumed + mParentOffsetInWindow[1]; 120 | if (dy < 0 && !canChildScrollUp()) { 121 | mTotalUnconsumed += Math.abs(dy); 122 | moveSpinner(mTotalUnconsumed); 123 | } 124 | } 125 | ``` 126 | 127 | 最后是 `onStopNestedScroll`,这个就比较简单了,主要是做一些动画和状态设置,代码我就不贴了。 128 | 129 | 作为 Child 部分的实现就相对简单了,基本都是使用 `NestedScrollingChildHelper` 这个助手类来去实现的。这里也不再赘述了。 130 | 131 | 一点补充,如果大家看源码的话可以发现,SRL 中实际也使用了传统的重写 `onInterceptTouchEvent` 的方法来处理嵌套滚动,这是为了适应子视图未实现现代 Nested Scrolling 的情况,如果子视图是 `RecyclerView` 或其他支持 Nested Scrolling 的视图,就不会引起后续的传统处理方式: 132 | ```java 133 | @Override 134 | public boolean onInterceptTouchEvent(MotionEvent ev) { 135 | ensureTarget(); 136 | 137 | final int action = MotionEventCompat.getActionMasked(ev); 138 | int pointerIndex; 139 | 140 | if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 141 | mReturningToStart = false; 142 | } 143 | 144 | if (!isEnabled() || mReturningToStart || canChildScrollUp() 145 | || mRefreshing || mNestedScrollInProgress) { 146 | // Fail fast if we're not in a state where a swipe is possible 147 | return false; 148 | } 149 | 150 | ... 151 | } 152 | ``` 153 | 154 | `mNestedScrollInProgress` 变量就可以用于判断子视图是否支持 Nested Scrolling,因为在 Nested Scrolling 开始后这个值就被设置为 `true` 了。 155 | 156 | ## 动画效果的实现分析 157 | 158 | 整个控件的视觉元素很少,就包含一个圆形的刷新指示器,这个是由 `CircleImageView` 和 `MaterialProgressDrawable` 这两个类来实现的,并由该方法添加到 SRL 视图中: 159 | ```java 160 | private void createProgressView() { 161 | mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT); 162 | mProgress = new MaterialProgressDrawable(getContext(), this); 163 | mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); 164 | mCircleView.setImageDrawable(mProgress); 165 | mCircleView.setVisibility(View.GONE); 166 | addView(mCircleView); 167 | } 168 | ``` 169 | 170 | 其中 `MaterialProgressDrawable` 就绘制了那个 **Material Design** 风格的 Spinner,它也被用于 `ProgressBar` 等控件中,而 `CircleImageView` 只是一个含圆形底图的一个 `ImageView`。后者其实没有什么值得深入分析的,因为 API level 21 之后就可以直接通过 `setElevation` 来设置视图的阴影效果了,这个类主要通过自定义绘制处理了老版本的兼容问题。 171 | 172 | 另一个有意思的点就是 SRL 处理动画的方式,举一个例子,`animateOffsetToCorrectPosition` 这个方法中使用到了一个名为 `mAnimateToCorrectPosition` 的 `Animation` 对象,它的定义如下: 173 | ```java 174 | private final Animation mAnimateToCorrectPosition = new Animation() { 175 | @Override 176 | public void applyTransformation(float interpolatedTime, Transformation t) { 177 | int targetTop = 0; 178 | int endTarget = 0; 179 | if (!mUsingCustomStart) { 180 | endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop); 181 | } else { 182 | endTarget = mSpinnerOffsetEnd; 183 | } 184 | targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); 185 | int offset = targetTop - mCircleView.getTop(); 186 | setTargetOffsetTopAndBottom(offset, false /* requires update */); 187 | mProgress.setArrowScale(1 - interpolatedTime); 188 | } 189 | }; 190 | ``` 191 | 192 | 这里同时控制了 `CircleImageView` 的位置和 `MaterialProgressDrawable` 的箭头大小,没有使用 `ValueAnimator` 等 `Animator` 来实现动画的目的是什么就不得而知了(个人猜测是为了兼容 Android 3.0 下的版本,毕竟 support 包的东西在 2.x 下都可以正常使用),大家如果知道的话也可以来提 Issue。 193 | 194 | 另外,SRL 作为一个 ViewGroup 也有自己的布局逻辑,我们也可以简单看一下: 195 | ```java 196 | @Override 197 | protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 198 | final int width = getMeasuredWidth(); 199 | final int height = getMeasuredHeight(); 200 | if (getChildCount() == 0) { 201 | return; 202 | } 203 | if (mTarget == null) { 204 | ensureTarget(); 205 | } 206 | if (mTarget == null) { 207 | return; 208 | } 209 | final View child = mTarget; 210 | final int childLeft = getPaddingLeft(); 211 | final int childTop = getPaddingTop(); 212 | final int childWidth = width - getPaddingLeft() - getPaddingRight(); 213 | final int childHeight = height - getPaddingTop() - getPaddingBottom(); 214 | child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); 215 | int circleWidth = mCircleView.getMeasuredWidth(); 216 | int circleHeight = mCircleView.getMeasuredHeight(); 217 | mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, 218 | (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); 219 | } 220 | 221 | @Override 222 | public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 223 | super.onMeasure(widthMeasureSpec, heightMeasureSpec); 224 | if (mTarget == null) { 225 | ensureTarget(); 226 | } 227 | if (mTarget == null) { 228 | return; 229 | } 230 | mTarget.measure(MeasureSpec.makeMeasureSpec( 231 | getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), 232 | MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( 233 | getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); 234 | mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY), 235 | MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY)); 236 | mCircleViewIndex = -1; 237 | // Get the index of the circleview. 238 | for (int index = 0; index < getChildCount(); index++) { 239 | if (getChildAt(index) == mCircleView) { 240 | mCircleViewIndex = index; 241 | break; 242 | } 243 | } 244 | } 245 | ``` 246 | 247 | 概括地说就是,对于内容子视图,就让它填满整个 SRL,这也就是说 SRL 会自动填满它的父视图,不管它的尺寸是 `MATCH_PARENT` 还是 `WRAP_CONTENT`,然后让它的子视图也填满父视图。对于 Spinner,SRL 就 hardcode 了它的大小,位置则由 `mCurrentTargetOffsetTop` 决定,并始终水平居中。 248 | 249 | 为了让 Spinner 始终显示在最上面,SRL 也自定义了绘制顺序: 250 | ```java 251 | @Override 252 | protected int getChildDrawingOrder(int childCount, int i) { 253 | if (mCircleViewIndex < 0) { 254 | // Spinner 的 index 还未被计算出来(可能还没 measure),fallback 到 super 的处理方式。 255 | // super 的处理方式实际就是直接返回 i。 256 | return i; 257 | } else if (i == childCount - 1) { 258 | // Draw the selected child last 259 | return mCircleViewIndex; 260 | } else if (i >= mCircleViewIndex) { 261 | // Move the children after the selected child earlier one 262 | return i + 1; 263 | } else { 264 | // Keep the children before the selected child the same 265 | return i; 266 | } 267 | } 268 | ``` 269 | 270 | 简单讲解一下这个方法的作用,i 这个参数代表了当前应该绘制第几个的视图,返回值则是选中视图的 index,举个简单的例子: 271 | 272 | 我依次向一个 ViewGroup 中加入了 A、B、C、D 四个视图,按理说 D 是在最上面的,但如果我想让 D 始终显示在最下面,怎么做呢?我们只要让 `getChildDrawingOrder` 能达到下面的这个函数映射就可以了: 273 | 274 | | i | 返回值 | 275 | | - | :-: | 276 | | 0 | 3 | 277 | | 1 | 0 | 278 | | 2 | 1 | 279 | | 3 | 2 | 280 | 281 | 在 SRL 中,最后绘制的视图始终是 Spinner,所以当 `i == childCount - 1` 的时候,就直接返回 Spinner 视图的 index,但其它的视图顺序也需要处理,所以如果当正在绘制的不是最后一个视图,然而 Spinner 却出现了,那就需要跳过 Spinner,先绘制下一个视图,也就是第 `i + 1` 个视图;对于 Spinner 之前的视图就采用默认顺序就可以了。这块可能比较抽象,大家列列表格就能推出来了。 282 | 283 | 到这里我们基本就分析完 SRL 里面几个比较核心的方法了,其它的细节本文就不再赘述了,相信大家通过阅读源码都能理解。 -------------------------------------------------------------------------------- /SwipeRefreshLayout/SwipeRefreshLayout.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2013 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package android.support.v4.widget; 18 | 19 | import android.content.Context; 20 | import android.content.res.Resources; 21 | import android.content.res.TypedArray; 22 | import android.support.annotation.ColorInt; 23 | import android.support.annotation.ColorRes; 24 | import android.support.annotation.Nullable; 25 | import android.support.annotation.VisibleForTesting; 26 | import android.support.v4.content.ContextCompat; 27 | import android.support.v4.view.MotionEventCompat; 28 | import android.support.v4.view.NestedScrollingChild; 29 | import android.support.v4.view.NestedScrollingChildHelper; 30 | import android.support.v4.view.NestedScrollingParent; 31 | import android.support.v4.view.NestedScrollingParentHelper; 32 | import android.support.v4.view.ViewCompat; 33 | import android.util.AttributeSet; 34 | import android.util.DisplayMetrics; 35 | import android.util.Log; 36 | import android.view.MotionEvent; 37 | import android.view.View; 38 | import android.view.ViewConfiguration; 39 | import android.view.ViewGroup; 40 | import android.view.animation.Animation; 41 | import android.view.animation.Animation.AnimationListener; 42 | import android.view.animation.DecelerateInterpolator; 43 | import android.view.animation.Transformation; 44 | import android.widget.AbsListView; 45 | 46 | /** 47 | * The SwipeRefreshLayout should be used whenever the user can refresh the 48 | * contents of a view via a vertical swipe gesture. The activity that 49 | * instantiates this view should add an OnRefreshListener to be notified 50 | * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout 51 | * will notify the listener each and every time the gesture is completed again; 52 | * the listener is responsible for correctly determining when to actually 53 | * initiate a refresh of its content. If the listener determines there should 54 | * not be a refresh, it must call setRefreshing(false) to cancel any visual 55 | * indication of a refresh. If an activity wishes to show just the progress 56 | * animation, it should call setRefreshing(true). To disable the gesture and 57 | * progress animation, call setEnabled(false) on the view. 58 | *

59 | * This layout should be made the parent of the view that will be refreshed as a 60 | * result of the gesture and can only support one direct child. This view will 61 | * also be made the target of the gesture and will be forced to match both the 62 | * width and the height supplied in this layout. The SwipeRefreshLayout does not 63 | * provide accessibility events; instead, a menu item must be provided to allow 64 | * refresh of the content wherever this gesture is used. 65 | *

66 | */ 67 | public class SwipeRefreshLayout extends ViewGroup implements NestedScrollingParent, 68 | NestedScrollingChild { 69 | // Maps to ProgressBar.Large style 70 | public static final int LARGE = MaterialProgressDrawable.LARGE; 71 | // Maps to ProgressBar default style 72 | public static final int DEFAULT = MaterialProgressDrawable.DEFAULT; 73 | 74 | @VisibleForTesting 75 | static final int CIRCLE_DIAMETER = 40; 76 | @VisibleForTesting 77 | static final int CIRCLE_DIAMETER_LARGE = 56; 78 | 79 | private static final String LOG_TAG = SwipeRefreshLayout.class.getSimpleName(); 80 | 81 | private static final int MAX_ALPHA = 255; 82 | private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA); 83 | 84 | private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; 85 | private static final int INVALID_POINTER = -1; 86 | private static final float DRAG_RATE = .5f; 87 | 88 | // Max amount of circle that can be filled by progress during swipe gesture, 89 | // where 1.0 is a full circle 90 | private static final float MAX_PROGRESS_ANGLE = .8f; 91 | 92 | private static final int SCALE_DOWN_DURATION = 150; 93 | 94 | private static final int ALPHA_ANIMATION_DURATION = 300; 95 | 96 | private static final int ANIMATE_TO_TRIGGER_DURATION = 200; 97 | 98 | private static final int ANIMATE_TO_START_DURATION = 200; 99 | 100 | // Default background for the progress spinner 101 | private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA; 102 | // Default offset in dips from the top of the view to where the progress spinner should stop 103 | private static final int DEFAULT_CIRCLE_TARGET = 64; 104 | 105 | private View mTarget; // the target of the gesture 106 | OnRefreshListener mListener; 107 | boolean mRefreshing = false; 108 | private int mTouchSlop; 109 | private float mTotalDragDistance = -1; 110 | 111 | // If nested scrolling is enabled, the total amount that needed to be 112 | // consumed by this as the nested scrolling parent is used in place of the 113 | // overscroll determined by MOVE events in the onTouch handler 114 | private float mTotalUnconsumed; 115 | private final NestedScrollingParentHelper mNestedScrollingParentHelper; 116 | private final NestedScrollingChildHelper mNestedScrollingChildHelper; 117 | private final int[] mParentScrollConsumed = new int[2]; 118 | private final int[] mParentOffsetInWindow = new int[2]; 119 | private boolean mNestedScrollInProgress; 120 | 121 | private int mMediumAnimationDuration; 122 | int mCurrentTargetOffsetTop; 123 | 124 | private float mInitialMotionY; 125 | private float mInitialDownY; 126 | private boolean mIsBeingDragged; 127 | private int mActivePointerId = INVALID_POINTER; 128 | // Whether this item is scaled up rather than clipped 129 | boolean mScale; 130 | 131 | // Target is returning to its start offset because it was cancelled or a 132 | // refresh was triggered. 133 | private boolean mReturningToStart; 134 | private final DecelerateInterpolator mDecelerateInterpolator; 135 | private static final int[] LAYOUT_ATTRS = new int[] { 136 | android.R.attr.enabled 137 | }; 138 | 139 | CircleImageView mCircleView; 140 | private int mCircleViewIndex = -1; 141 | 142 | protected int mFrom; 143 | 144 | float mStartingScale; 145 | 146 | protected int mOriginalOffsetTop; 147 | 148 | int mSpinnerOffsetEnd; 149 | 150 | MaterialProgressDrawable mProgress; 151 | 152 | private Animation mScaleAnimation; 153 | 154 | private Animation mScaleDownAnimation; 155 | 156 | private Animation mAlphaStartAnimation; 157 | 158 | private Animation mAlphaMaxAnimation; 159 | 160 | private Animation mScaleDownToStartAnimation; 161 | 162 | boolean mNotify; 163 | 164 | private int mCircleDiameter; 165 | 166 | // Whether the client has set a custom starting position; 167 | boolean mUsingCustomStart; 168 | 169 | private OnChildScrollUpCallback mChildScrollUpCallback; 170 | 171 | private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() { 172 | @Override 173 | public void onAnimationStart(Animation animation) { 174 | } 175 | 176 | @Override 177 | public void onAnimationRepeat(Animation animation) { 178 | } 179 | 180 | @Override 181 | public void onAnimationEnd(Animation animation) { 182 | if (mRefreshing) { 183 | // Make sure the progress view is fully visible 184 | mProgress.setAlpha(MAX_ALPHA); 185 | mProgress.start(); 186 | if (mNotify) { 187 | if (mListener != null) { 188 | mListener.onRefresh(); 189 | } 190 | } 191 | mCurrentTargetOffsetTop = mCircleView.getTop(); 192 | } else { 193 | reset(); 194 | } 195 | } 196 | }; 197 | 198 | void reset() { 199 | mCircleView.clearAnimation(); 200 | mProgress.stop(); 201 | mCircleView.setVisibility(View.GONE); 202 | setColorViewAlpha(MAX_ALPHA); 203 | // Return the circle to its start position 204 | if (mScale) { 205 | setAnimationProgress(0 /* animation complete and view is hidden */); 206 | } else { 207 | setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop, 208 | true /* requires update */); 209 | } 210 | mCurrentTargetOffsetTop = mCircleView.getTop(); 211 | } 212 | 213 | @Override 214 | public void setEnabled(boolean enabled) { 215 | super.setEnabled(enabled); 216 | if (!enabled) { 217 | reset(); 218 | } 219 | } 220 | 221 | @Override 222 | protected void onDetachedFromWindow() { 223 | super.onDetachedFromWindow(); 224 | reset(); 225 | } 226 | 227 | private void setColorViewAlpha(int targetAlpha) { 228 | mCircleView.getBackground().setAlpha(targetAlpha); 229 | mProgress.setAlpha(targetAlpha); 230 | } 231 | 232 | /** 233 | * The refresh indicator starting and resting position is always positioned 234 | * near the top of the refreshing content. This position is a consistent 235 | * location, but can be adjusted in either direction based on whether or not 236 | * there is a toolbar or actionbar present. 237 | *

238 | * Note: Calling this will reset the position of the refresh indicator to 239 | * start. 240 | *

241 | * 242 | * @param scale Set to true if there is no view at a higher z-order than where the progress 243 | * spinner is set to appear. Setting it to true will cause indicator to be scaled 244 | * up rather than clipped. 245 | * @param start The offset in pixels from the top of this view at which the 246 | * progress spinner should appear. 247 | * @param end The offset in pixels from the top of this view at which the 248 | * progress spinner should come to rest after a successful swipe 249 | * gesture. 250 | */ 251 | public void setProgressViewOffset(boolean scale, int start, int end) { 252 | mScale = scale; 253 | mOriginalOffsetTop = start; 254 | mSpinnerOffsetEnd = end; 255 | mUsingCustomStart = true; 256 | reset(); 257 | mRefreshing = false; 258 | } 259 | 260 | /** 261 | * @return The offset in pixels from the top of this view at which the progress spinner should 262 | * appear. 263 | */ 264 | public int getProgressViewStartOffset() { 265 | return mOriginalOffsetTop; 266 | } 267 | 268 | /** 269 | * @return The offset in pixels from the top of this view at which the progress spinner should 270 | * come to rest after a successful swipe gesture. 271 | */ 272 | public int getProgressViewEndOffset() { 273 | return mSpinnerOffsetEnd; 274 | } 275 | 276 | /** 277 | * The refresh indicator resting position is always positioned near the top 278 | * of the refreshing content. This position is a consistent location, but 279 | * can be adjusted in either direction based on whether or not there is a 280 | * toolbar or actionbar present. 281 | * 282 | * @param scale Set to true if there is no view at a higher z-order than where the progress 283 | * spinner is set to appear. Setting it to true will cause indicator to be scaled 284 | * up rather than clipped. 285 | * @param end The offset in pixels from the top of this view at which the 286 | * progress spinner should come to rest after a successful swipe 287 | * gesture. 288 | */ 289 | public void setProgressViewEndTarget(boolean scale, int end) { 290 | mSpinnerOffsetEnd = end; 291 | mScale = scale; 292 | mCircleView.invalidate(); 293 | } 294 | 295 | /** 296 | * One of DEFAULT, or LARGE. 297 | */ 298 | public void setSize(int size) { 299 | if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) { 300 | return; 301 | } 302 | final DisplayMetrics metrics = getResources().getDisplayMetrics(); 303 | if (size == MaterialProgressDrawable.LARGE) { 304 | mCircleDiameter = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); 305 | } else { 306 | mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); 307 | } 308 | // force the bounds of the progress circle inside the circle view to 309 | // update by setting it to null before updating its size and then 310 | // re-setting it 311 | mCircleView.setImageDrawable(null); 312 | mProgress.updateSizes(size); 313 | mCircleView.setImageDrawable(mProgress); 314 | } 315 | 316 | /** 317 | * Simple constructor to use when creating a SwipeRefreshLayout from code. 318 | * 319 | * @param context 320 | */ 321 | public SwipeRefreshLayout(Context context) { 322 | this(context, null); 323 | } 324 | 325 | /** 326 | * Constructor that is called when inflating SwipeRefreshLayout from XML. 327 | * 328 | * @param context 329 | * @param attrs 330 | */ 331 | public SwipeRefreshLayout(Context context, AttributeSet attrs) { 332 | super(context, attrs); 333 | 334 | mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); 335 | 336 | mMediumAnimationDuration = getResources().getInteger( 337 | android.R.integer.config_mediumAnimTime); 338 | 339 | setWillNotDraw(false); 340 | mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); 341 | 342 | final DisplayMetrics metrics = getResources().getDisplayMetrics(); 343 | mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); 344 | 345 | createProgressView(); 346 | ViewCompat.setChildrenDrawingOrderEnabled(this, true); 347 | // the absolute offset has to take into account that the circle starts at an offset 348 | mSpinnerOffsetEnd = (int) (DEFAULT_CIRCLE_TARGET * metrics.density); 349 | mTotalDragDistance = mSpinnerOffsetEnd; 350 | mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); 351 | 352 | mNestedScrollingChildHelper = new NestedScrollingChildHelper(this); 353 | setNestedScrollingEnabled(true); 354 | 355 | mOriginalOffsetTop = mCurrentTargetOffsetTop = -mCircleDiameter; 356 | moveToStart(1.0f); 357 | 358 | final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); 359 | setEnabled(a.getBoolean(0, true)); 360 | a.recycle(); 361 | } 362 | 363 | @Override 364 | protected int getChildDrawingOrder(int childCount, int i) { 365 | if (mCircleViewIndex < 0) { 366 | return i; 367 | } else if (i == childCount - 1) { 368 | // Draw the selected child last 369 | return mCircleViewIndex; 370 | } else if (i >= mCircleViewIndex) { 371 | // Move the children after the selected child earlier one 372 | return i + 1; 373 | } else { 374 | // Keep the children before the selected child the same 375 | return i; 376 | } 377 | } 378 | 379 | private void createProgressView() { 380 | mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT); 381 | mProgress = new MaterialProgressDrawable(getContext(), this); 382 | mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); 383 | mCircleView.setImageDrawable(mProgress); 384 | mCircleView.setVisibility(View.GONE); 385 | addView(mCircleView); 386 | } 387 | 388 | /** 389 | * Set the listener to be notified when a refresh is triggered via the swipe 390 | * gesture. 391 | */ 392 | public void setOnRefreshListener(OnRefreshListener listener) { 393 | mListener = listener; 394 | } 395 | 396 | /** 397 | * Pre API 11, alpha is used to make the progress circle appear instead of scale. 398 | */ 399 | private boolean isAlphaUsedForScale() { 400 | return android.os.Build.VERSION.SDK_INT < 11; 401 | } 402 | 403 | /** 404 | * Notify the widget that refresh state has changed. Do not call this when 405 | * refresh is triggered by a swipe gesture. 406 | * 407 | * @param refreshing Whether or not the view should show refresh progress. 408 | */ 409 | public void setRefreshing(boolean refreshing) { 410 | if (refreshing && mRefreshing != refreshing) { 411 | // scale and show 412 | mRefreshing = refreshing; 413 | int endTarget = 0; 414 | if (!mUsingCustomStart) { 415 | endTarget = mSpinnerOffsetEnd + mOriginalOffsetTop; 416 | } else { 417 | endTarget = mSpinnerOffsetEnd; 418 | } 419 | setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop, 420 | true /* requires update */); 421 | mNotify = false; 422 | startScaleUpAnimation(mRefreshListener); 423 | } else { 424 | setRefreshing(refreshing, false /* notify */); 425 | } 426 | } 427 | 428 | private void startScaleUpAnimation(AnimationListener listener) { 429 | mCircleView.setVisibility(View.VISIBLE); 430 | if (android.os.Build.VERSION.SDK_INT >= 11) { 431 | // Pre API 11, alpha is used in place of scale up to show the 432 | // progress circle appearing. 433 | // Don't adjust the alpha during appearance otherwise. 434 | mProgress.setAlpha(MAX_ALPHA); 435 | } 436 | mScaleAnimation = new Animation() { 437 | @Override 438 | public void applyTransformation(float interpolatedTime, Transformation t) { 439 | setAnimationProgress(interpolatedTime); 440 | } 441 | }; 442 | mScaleAnimation.setDuration(mMediumAnimationDuration); 443 | if (listener != null) { 444 | mCircleView.setAnimationListener(listener); 445 | } 446 | mCircleView.clearAnimation(); 447 | mCircleView.startAnimation(mScaleAnimation); 448 | } 449 | 450 | /** 451 | * Pre API 11, this does an alpha animation. 452 | * @param progress 453 | */ 454 | void setAnimationProgress(float progress) { 455 | if (isAlphaUsedForScale()) { 456 | setColorViewAlpha((int) (progress * MAX_ALPHA)); 457 | } else { 458 | ViewCompat.setScaleX(mCircleView, progress); 459 | ViewCompat.setScaleY(mCircleView, progress); 460 | } 461 | } 462 | 463 | private void setRefreshing(boolean refreshing, final boolean notify) { 464 | if (mRefreshing != refreshing) { 465 | mNotify = notify; 466 | ensureTarget(); 467 | mRefreshing = refreshing; 468 | if (mRefreshing) { 469 | animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); 470 | } else { 471 | startScaleDownAnimation(mRefreshListener); 472 | } 473 | } 474 | } 475 | 476 | void startScaleDownAnimation(Animation.AnimationListener listener) { 477 | mScaleDownAnimation = new Animation() { 478 | @Override 479 | public void applyTransformation(float interpolatedTime, Transformation t) { 480 | setAnimationProgress(1 - interpolatedTime); 481 | } 482 | }; 483 | mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); 484 | mCircleView.setAnimationListener(listener); 485 | mCircleView.clearAnimation(); 486 | mCircleView.startAnimation(mScaleDownAnimation); 487 | } 488 | 489 | private void startProgressAlphaStartAnimation() { 490 | mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); 491 | } 492 | 493 | private void startProgressAlphaMaxAnimation() { 494 | mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); 495 | } 496 | 497 | private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { 498 | // Pre API 11, alpha is used in place of scale. Don't also use it to 499 | // show the trigger point. 500 | if (mScale && isAlphaUsedForScale()) { 501 | return null; 502 | } 503 | Animation alpha = new Animation() { 504 | @Override 505 | public void applyTransformation(float interpolatedTime, Transformation t) { 506 | mProgress.setAlpha( 507 | (int) (startingAlpha + ((endingAlpha - startingAlpha) * interpolatedTime))); 508 | } 509 | }; 510 | alpha.setDuration(ALPHA_ANIMATION_DURATION); 511 | // Clear out the previous animation listeners. 512 | mCircleView.setAnimationListener(null); 513 | mCircleView.clearAnimation(); 514 | mCircleView.startAnimation(alpha); 515 | return alpha; 516 | } 517 | 518 | /** 519 | * @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)} 520 | */ 521 | @Deprecated 522 | public void setProgressBackgroundColor(int colorRes) { 523 | setProgressBackgroundColorSchemeResource(colorRes); 524 | } 525 | 526 | /** 527 | * Set the background color of the progress spinner disc. 528 | * 529 | * @param colorRes Resource id of the color. 530 | */ 531 | public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) { 532 | setProgressBackgroundColorSchemeColor(ContextCompat.getColor(getContext(), colorRes)); 533 | } 534 | 535 | /** 536 | * Set the background color of the progress spinner disc. 537 | * 538 | * @param color 539 | */ 540 | public void setProgressBackgroundColorSchemeColor(@ColorInt int color) { 541 | mCircleView.setBackgroundColor(color); 542 | mProgress.setBackgroundColor(color); 543 | } 544 | 545 | /** 546 | * @deprecated Use {@link #setColorSchemeResources(int...)} 547 | */ 548 | @Deprecated 549 | public void setColorScheme(@ColorInt int... colors) { 550 | setColorSchemeResources(colors); 551 | } 552 | 553 | /** 554 | * Set the color resources used in the progress animation from color resources. 555 | * The first color will also be the color of the bar that grows in response 556 | * to a user swipe gesture. 557 | * 558 | * @param colorResIds 559 | */ 560 | public void setColorSchemeResources(@ColorRes int... colorResIds) { 561 | final Context context = getContext(); 562 | int[] colorRes = new int[colorResIds.length]; 563 | for (int i = 0; i < colorResIds.length; i++) { 564 | colorRes[i] = ContextCompat.getColor(context, colorResIds[i]); 565 | } 566 | setColorSchemeColors(colorRes); 567 | } 568 | 569 | /** 570 | * Set the colors used in the progress animation. The first 571 | * color will also be the color of the bar that grows in response to a user 572 | * swipe gesture. 573 | * 574 | * @param colors 575 | */ 576 | public void setColorSchemeColors(@ColorInt int... colors) { 577 | ensureTarget(); 578 | mProgress.setColorSchemeColors(colors); 579 | } 580 | 581 | /** 582 | * @return Whether the SwipeRefreshWidget is actively showing refresh 583 | * progress. 584 | */ 585 | public boolean isRefreshing() { 586 | return mRefreshing; 587 | } 588 | 589 | private void ensureTarget() { 590 | // Don't bother getting the parent height if the parent hasn't been laid 591 | // out yet. 592 | if (mTarget == null) { 593 | for (int i = 0; i < getChildCount(); i++) { 594 | View child = getChildAt(i); 595 | if (!child.equals(mCircleView)) { 596 | mTarget = child; 597 | break; 598 | } 599 | } 600 | } 601 | } 602 | 603 | /** 604 | * Set the distance to trigger a sync in dips 605 | * 606 | * @param distance 607 | */ 608 | public void setDistanceToTriggerSync(int distance) { 609 | mTotalDragDistance = distance; 610 | } 611 | 612 | @Override 613 | protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 614 | final int width = getMeasuredWidth(); 615 | final int height = getMeasuredHeight(); 616 | if (getChildCount() == 0) { 617 | return; 618 | } 619 | if (mTarget == null) { 620 | ensureTarget(); 621 | } 622 | if (mTarget == null) { 623 | return; 624 | } 625 | final View child = mTarget; 626 | final int childLeft = getPaddingLeft(); 627 | final int childTop = getPaddingTop(); 628 | final int childWidth = width - getPaddingLeft() - getPaddingRight(); 629 | final int childHeight = height - getPaddingTop() - getPaddingBottom(); 630 | child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); 631 | int circleWidth = mCircleView.getMeasuredWidth(); 632 | int circleHeight = mCircleView.getMeasuredHeight(); 633 | mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, 634 | (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); 635 | } 636 | 637 | @Override 638 | public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 639 | super.onMeasure(widthMeasureSpec, heightMeasureSpec); 640 | if (mTarget == null) { 641 | ensureTarget(); 642 | } 643 | if (mTarget == null) { 644 | return; 645 | } 646 | mTarget.measure(MeasureSpec.makeMeasureSpec( 647 | getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), 648 | MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( 649 | getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); 650 | mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY), 651 | MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY)); 652 | mCircleViewIndex = -1; 653 | // Get the index of the circleview. 654 | for (int index = 0; index < getChildCount(); index++) { 655 | if (getChildAt(index) == mCircleView) { 656 | mCircleViewIndex = index; 657 | break; 658 | } 659 | } 660 | } 661 | 662 | /** 663 | * Get the diameter of the progress circle that is displayed as part of the 664 | * swipe to refresh layout. 665 | * 666 | * @return Diameter in pixels of the progress circle view. 667 | */ 668 | public int getProgressCircleDiameter() { 669 | return mCircleDiameter; 670 | } 671 | 672 | /** 673 | * @return Whether it is possible for the child view of this layout to 674 | * scroll up. Override this if the child view is a custom view. 675 | */ 676 | public boolean canChildScrollUp() { 677 | if (mChildScrollUpCallback != null) { 678 | return mChildScrollUpCallback.canChildScrollUp(this, mTarget); 679 | } 680 | if (android.os.Build.VERSION.SDK_INT < 14) { 681 | if (mTarget instanceof AbsListView) { 682 | final AbsListView absListView = (AbsListView) mTarget; 683 | return absListView.getChildCount() > 0 684 | && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) 685 | .getTop() < absListView.getPaddingTop()); 686 | } else { 687 | return ViewCompat.canScrollVertically(mTarget, -1) || mTarget.getScrollY() > 0; 688 | } 689 | } else { 690 | return ViewCompat.canScrollVertically(mTarget, -1); 691 | } 692 | } 693 | 694 | /** 695 | * Set a callback to override {@link SwipeRefreshLayout#canChildScrollUp()} method. Non-null 696 | * callback will return the value provided by the callback and ignore all internal logic. 697 | * @param callback Callback that should be called when canChildScrollUp() is called. 698 | */ 699 | public void setOnChildScrollUpCallback(@Nullable OnChildScrollUpCallback callback) { 700 | mChildScrollUpCallback = callback; 701 | } 702 | 703 | @Override 704 | public boolean onInterceptTouchEvent(MotionEvent ev) { 705 | ensureTarget(); 706 | 707 | final int action = MotionEventCompat.getActionMasked(ev); 708 | int pointerIndex; 709 | 710 | if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 711 | mReturningToStart = false; 712 | } 713 | 714 | if (!isEnabled() || mReturningToStart || canChildScrollUp() 715 | || mRefreshing || mNestedScrollInProgress) { 716 | // Fail fast if we're not in a state where a swipe is possible 717 | return false; 718 | } 719 | 720 | switch (action) { 721 | case MotionEvent.ACTION_DOWN: 722 | setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true); 723 | mActivePointerId = ev.getPointerId(0); 724 | mIsBeingDragged = false; 725 | 726 | pointerIndex = ev.findPointerIndex(mActivePointerId); 727 | if (pointerIndex < 0) { 728 | return false; 729 | } 730 | mInitialDownY = ev.getY(pointerIndex); 731 | break; 732 | 733 | case MotionEvent.ACTION_MOVE: 734 | if (mActivePointerId == INVALID_POINTER) { 735 | Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); 736 | return false; 737 | } 738 | 739 | pointerIndex = ev.findPointerIndex(mActivePointerId); 740 | if (pointerIndex < 0) { 741 | return false; 742 | } 743 | final float y = ev.getY(pointerIndex); 744 | startDragging(y); 745 | break; 746 | 747 | case MotionEventCompat.ACTION_POINTER_UP: 748 | onSecondaryPointerUp(ev); 749 | break; 750 | 751 | case MotionEvent.ACTION_UP: 752 | case MotionEvent.ACTION_CANCEL: 753 | mIsBeingDragged = false; 754 | mActivePointerId = INVALID_POINTER; 755 | break; 756 | } 757 | 758 | return mIsBeingDragged; 759 | } 760 | 761 | @Override 762 | public void requestDisallowInterceptTouchEvent(boolean b) { 763 | // if this is a List < L or another view that doesn't support nested 764 | // scrolling, ignore this request so that the vertical scroll event 765 | // isn't stolen 766 | if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView) 767 | || (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) { 768 | // Nope. 769 | } else { 770 | super.requestDisallowInterceptTouchEvent(b); 771 | } 772 | } 773 | 774 | // NestedScrollingParent 775 | 776 | @Override 777 | public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { 778 | return isEnabled() && !mReturningToStart && !mRefreshing 779 | && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; 780 | } 781 | 782 | @Override 783 | public void onNestedScrollAccepted(View child, View target, int axes) { 784 | // Reset the counter of how much leftover scroll needs to be consumed. 785 | mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); 786 | // Dispatch up to the nested parent 787 | startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL); 788 | mTotalUnconsumed = 0; 789 | mNestedScrollInProgress = true; 790 | } 791 | 792 | @Override 793 | public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { 794 | // If we are in the middle of consuming, a scroll, then we want to move the spinner back up 795 | // before allowing the list to scroll 796 | if (dy > 0 && mTotalUnconsumed > 0) { 797 | if (dy > mTotalUnconsumed) { 798 | consumed[1] = dy - (int) mTotalUnconsumed; 799 | mTotalUnconsumed = 0; 800 | } else { 801 | mTotalUnconsumed -= dy; 802 | consumed[1] = dy; 803 | } 804 | moveSpinner(mTotalUnconsumed); 805 | } 806 | 807 | // If a client layout is using a custom start position for the circle 808 | // view, they mean to hide it again before scrolling the child view 809 | // If we get back to mTotalUnconsumed == 0 and there is more to go, hide 810 | // the circle so it isn't exposed if its blocking content is moved 811 | if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0 812 | && Math.abs(dy - consumed[1]) > 0) { 813 | mCircleView.setVisibility(View.GONE); 814 | } 815 | 816 | // Now let our nested parent consume the leftovers 817 | final int[] parentConsumed = mParentScrollConsumed; 818 | if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) { 819 | consumed[0] += parentConsumed[0]; 820 | consumed[1] += parentConsumed[1]; 821 | } 822 | } 823 | 824 | @Override 825 | public int getNestedScrollAxes() { 826 | return mNestedScrollingParentHelper.getNestedScrollAxes(); 827 | } 828 | 829 | @Override 830 | public void onStopNestedScroll(View target) { 831 | mNestedScrollingParentHelper.onStopNestedScroll(target); 832 | mNestedScrollInProgress = false; 833 | // Finish the spinner for nested scrolling if we ever consumed any 834 | // unconsumed nested scroll 835 | if (mTotalUnconsumed > 0) { 836 | finishSpinner(mTotalUnconsumed); 837 | mTotalUnconsumed = 0; 838 | } 839 | // Dispatch up our nested parent 840 | stopNestedScroll(); 841 | } 842 | 843 | @Override 844 | public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed, 845 | final int dxUnconsumed, final int dyUnconsumed) { 846 | // Dispatch up to the nested parent first 847 | dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, 848 | mParentOffsetInWindow); 849 | 850 | // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are 851 | // sometimes between two nested scrolling views, we need a way to be able to know when any 852 | // nested scrolling parent has stopped handling events. We do that by using the 853 | // 'offset in window 'functionality to see if we have been moved from the event. 854 | // This is a decent indication of whether we should take over the event stream or not. 855 | final int dy = dyUnconsumed + mParentOffsetInWindow[1]; 856 | if (dy < 0 && !canChildScrollUp()) { 857 | mTotalUnconsumed += Math.abs(dy); 858 | moveSpinner(mTotalUnconsumed); 859 | } 860 | } 861 | 862 | // NestedScrollingChild 863 | 864 | @Override 865 | public void setNestedScrollingEnabled(boolean enabled) { 866 | mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled); 867 | } 868 | 869 | @Override 870 | public boolean isNestedScrollingEnabled() { 871 | return mNestedScrollingChildHelper.isNestedScrollingEnabled(); 872 | } 873 | 874 | @Override 875 | public boolean startNestedScroll(int axes) { 876 | return mNestedScrollingChildHelper.startNestedScroll(axes); 877 | } 878 | 879 | @Override 880 | public void stopNestedScroll() { 881 | mNestedScrollingChildHelper.stopNestedScroll(); 882 | } 883 | 884 | @Override 885 | public boolean hasNestedScrollingParent() { 886 | return mNestedScrollingChildHelper.hasNestedScrollingParent(); 887 | } 888 | 889 | @Override 890 | public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, 891 | int dyUnconsumed, int[] offsetInWindow) { 892 | return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, 893 | dxUnconsumed, dyUnconsumed, offsetInWindow); 894 | } 895 | 896 | @Override 897 | public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { 898 | return mNestedScrollingChildHelper.dispatchNestedPreScroll( 899 | dx, dy, consumed, offsetInWindow); 900 | } 901 | 902 | @Override 903 | public boolean onNestedPreFling(View target, float velocityX, 904 | float velocityY) { 905 | return dispatchNestedPreFling(velocityX, velocityY); 906 | } 907 | 908 | @Override 909 | public boolean onNestedFling(View target, float velocityX, float velocityY, 910 | boolean consumed) { 911 | return dispatchNestedFling(velocityX, velocityY, consumed); 912 | } 913 | 914 | @Override 915 | public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { 916 | return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); 917 | } 918 | 919 | @Override 920 | public boolean dispatchNestedPreFling(float velocityX, float velocityY) { 921 | return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); 922 | } 923 | 924 | private boolean isAnimationRunning(Animation animation) { 925 | return animation != null && animation.hasStarted() && !animation.hasEnded(); 926 | } 927 | 928 | private void moveSpinner(float overscrollTop) { 929 | mProgress.showArrow(true); 930 | float originalDragPercent = overscrollTop / mTotalDragDistance; 931 | 932 | float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); 933 | float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; 934 | float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; 935 | float slingshotDist = mUsingCustomStart ? mSpinnerOffsetEnd - mOriginalOffsetTop 936 | : mSpinnerOffsetEnd; 937 | float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) 938 | / slingshotDist); 939 | float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( 940 | (tensionSlingshotPercent / 4), 2)) * 2f; 941 | float extraMove = (slingshotDist) * tensionPercent * 2; 942 | 943 | int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove); 944 | // where 1.0f is a full circle 945 | if (mCircleView.getVisibility() != View.VISIBLE) { 946 | mCircleView.setVisibility(View.VISIBLE); 947 | } 948 | if (!mScale) { 949 | ViewCompat.setScaleX(mCircleView, 1f); 950 | ViewCompat.setScaleY(mCircleView, 1f); 951 | } 952 | 953 | if (mScale) { 954 | setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance)); 955 | } 956 | if (overscrollTop < mTotalDragDistance) { 957 | if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA 958 | && !isAnimationRunning(mAlphaStartAnimation)) { 959 | // Animate the alpha 960 | startProgressAlphaStartAnimation(); 961 | } 962 | } else { 963 | if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) { 964 | // Animate the alpha 965 | startProgressAlphaMaxAnimation(); 966 | } 967 | } 968 | float strokeStart = adjustedPercent * .8f; 969 | mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); 970 | mProgress.setArrowScale(Math.min(1f, adjustedPercent)); 971 | 972 | float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; 973 | mProgress.setProgressRotation(rotation); 974 | setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop, true /* requires update */); 975 | } 976 | 977 | private void finishSpinner(float overscrollTop) { 978 | if (overscrollTop > mTotalDragDistance) { 979 | setRefreshing(true, true /* notify */); 980 | } else { 981 | // cancel refresh 982 | mRefreshing = false; 983 | mProgress.setStartEndTrim(0f, 0f); 984 | Animation.AnimationListener listener = null; 985 | if (!mScale) { 986 | listener = new Animation.AnimationListener() { 987 | 988 | @Override 989 | public void onAnimationStart(Animation animation) { 990 | } 991 | 992 | @Override 993 | public void onAnimationEnd(Animation animation) { 994 | if (!mScale) { 995 | startScaleDownAnimation(null); 996 | } 997 | } 998 | 999 | @Override 1000 | public void onAnimationRepeat(Animation animation) { 1001 | } 1002 | 1003 | }; 1004 | } 1005 | animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); 1006 | mProgress.showArrow(false); 1007 | } 1008 | } 1009 | 1010 | @Override 1011 | public boolean onTouchEvent(MotionEvent ev) { 1012 | final int action = MotionEventCompat.getActionMasked(ev); 1013 | int pointerIndex = -1; 1014 | 1015 | if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 1016 | mReturningToStart = false; 1017 | } 1018 | 1019 | if (!isEnabled() || mReturningToStart || canChildScrollUp() 1020 | || mRefreshing || mNestedScrollInProgress) { 1021 | // Fail fast if we're not in a state where a swipe is possible 1022 | return false; 1023 | } 1024 | 1025 | switch (action) { 1026 | case MotionEvent.ACTION_DOWN: 1027 | mActivePointerId = ev.getPointerId(0); 1028 | mIsBeingDragged = false; 1029 | break; 1030 | 1031 | case MotionEvent.ACTION_MOVE: { 1032 | pointerIndex = ev.findPointerIndex(mActivePointerId); 1033 | if (pointerIndex < 0) { 1034 | Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); 1035 | return false; 1036 | } 1037 | 1038 | final float y = ev.getY(pointerIndex); 1039 | startDragging(y); 1040 | 1041 | if (mIsBeingDragged) { 1042 | final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 1043 | if (overscrollTop > 0) { 1044 | moveSpinner(overscrollTop); 1045 | } else { 1046 | return false; 1047 | } 1048 | } 1049 | break; 1050 | } 1051 | case MotionEventCompat.ACTION_POINTER_DOWN: { 1052 | pointerIndex = MotionEventCompat.getActionIndex(ev); 1053 | if (pointerIndex < 0) { 1054 | Log.e(LOG_TAG, 1055 | "Got ACTION_POINTER_DOWN event but have an invalid action index."); 1056 | return false; 1057 | } 1058 | mActivePointerId = ev.getPointerId(pointerIndex); 1059 | break; 1060 | } 1061 | 1062 | case MotionEventCompat.ACTION_POINTER_UP: 1063 | onSecondaryPointerUp(ev); 1064 | break; 1065 | 1066 | case MotionEvent.ACTION_UP: { 1067 | pointerIndex = ev.findPointerIndex(mActivePointerId); 1068 | if (pointerIndex < 0) { 1069 | Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); 1070 | return false; 1071 | } 1072 | 1073 | if (mIsBeingDragged) { 1074 | final float y = ev.getY(pointerIndex); 1075 | final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 1076 | mIsBeingDragged = false; 1077 | finishSpinner(overscrollTop); 1078 | } 1079 | mActivePointerId = INVALID_POINTER; 1080 | return false; 1081 | } 1082 | case MotionEvent.ACTION_CANCEL: 1083 | return false; 1084 | } 1085 | 1086 | return true; 1087 | } 1088 | 1089 | private void startDragging(float y) { 1090 | final float yDiff = y - mInitialDownY; 1091 | if (yDiff > mTouchSlop && !mIsBeingDragged) { 1092 | mInitialMotionY = mInitialDownY + mTouchSlop; 1093 | mIsBeingDragged = true; 1094 | mProgress.setAlpha(STARTING_PROGRESS_ALPHA); 1095 | } 1096 | } 1097 | 1098 | private void animateOffsetToCorrectPosition(int from, AnimationListener listener) { 1099 | mFrom = from; 1100 | mAnimateToCorrectPosition.reset(); 1101 | mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); 1102 | mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); 1103 | if (listener != null) { 1104 | mCircleView.setAnimationListener(listener); 1105 | } 1106 | mCircleView.clearAnimation(); 1107 | mCircleView.startAnimation(mAnimateToCorrectPosition); 1108 | } 1109 | 1110 | private void animateOffsetToStartPosition(int from, AnimationListener listener) { 1111 | if (mScale) { 1112 | // Scale the item back down 1113 | startScaleDownReturnToStartAnimation(from, listener); 1114 | } else { 1115 | mFrom = from; 1116 | mAnimateToStartPosition.reset(); 1117 | mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); 1118 | mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); 1119 | if (listener != null) { 1120 | mCircleView.setAnimationListener(listener); 1121 | } 1122 | mCircleView.clearAnimation(); 1123 | mCircleView.startAnimation(mAnimateToStartPosition); 1124 | } 1125 | } 1126 | 1127 | private final Animation mAnimateToCorrectPosition = new Animation() { 1128 | @Override 1129 | public void applyTransformation(float interpolatedTime, Transformation t) { 1130 | int targetTop = 0; 1131 | int endTarget = 0; 1132 | if (!mUsingCustomStart) { 1133 | endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop); 1134 | } else { 1135 | endTarget = mSpinnerOffsetEnd; 1136 | } 1137 | targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); 1138 | int offset = targetTop - mCircleView.getTop(); 1139 | setTargetOffsetTopAndBottom(offset, false /* requires update */); 1140 | mProgress.setArrowScale(1 - interpolatedTime); 1141 | } 1142 | }; 1143 | 1144 | void moveToStart(float interpolatedTime) { 1145 | int targetTop = 0; 1146 | targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); 1147 | int offset = targetTop - mCircleView.getTop(); 1148 | setTargetOffsetTopAndBottom(offset, false /* requires update */); 1149 | } 1150 | 1151 | private final Animation mAnimateToStartPosition = new Animation() { 1152 | @Override 1153 | public void applyTransformation(float interpolatedTime, Transformation t) { 1154 | moveToStart(interpolatedTime); 1155 | } 1156 | }; 1157 | 1158 | private void startScaleDownReturnToStartAnimation(int from, 1159 | Animation.AnimationListener listener) { 1160 | mFrom = from; 1161 | if (isAlphaUsedForScale()) { 1162 | mStartingScale = mProgress.getAlpha(); 1163 | } else { 1164 | mStartingScale = ViewCompat.getScaleX(mCircleView); 1165 | } 1166 | mScaleDownToStartAnimation = new Animation() { 1167 | @Override 1168 | public void applyTransformation(float interpolatedTime, Transformation t) { 1169 | float targetScale = (mStartingScale + (-mStartingScale * interpolatedTime)); 1170 | setAnimationProgress(targetScale); 1171 | moveToStart(interpolatedTime); 1172 | } 1173 | }; 1174 | mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION); 1175 | if (listener != null) { 1176 | mCircleView.setAnimationListener(listener); 1177 | } 1178 | mCircleView.clearAnimation(); 1179 | mCircleView.startAnimation(mScaleDownToStartAnimation); 1180 | } 1181 | 1182 | void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate) { 1183 | mCircleView.bringToFront(); 1184 | ViewCompat.offsetTopAndBottom(mCircleView, offset); 1185 | mCurrentTargetOffsetTop = mCircleView.getTop(); 1186 | if (requiresUpdate && android.os.Build.VERSION.SDK_INT < 11) { 1187 | invalidate(); 1188 | } 1189 | } 1190 | 1191 | private void onSecondaryPointerUp(MotionEvent ev) { 1192 | final int pointerIndex = MotionEventCompat.getActionIndex(ev); 1193 | final int pointerId = ev.getPointerId(pointerIndex); 1194 | if (pointerId == mActivePointerId) { 1195 | // This was our active pointer going up. Choose a new 1196 | // active pointer and adjust accordingly. 1197 | final int newPointerIndex = pointerIndex == 0 ? 1 : 0; 1198 | mActivePointerId = ev.getPointerId(newPointerIndex); 1199 | } 1200 | } 1201 | 1202 | /** 1203 | * Classes that wish to be notified when the swipe gesture correctly 1204 | * triggers a refresh should implement this interface. 1205 | */ 1206 | public interface OnRefreshListener { 1207 | /** 1208 | * Called when a swipe gesture triggers a refresh. 1209 | */ 1210 | void onRefresh(); 1211 | } 1212 | 1213 | /** 1214 | * Classes that wish to override {@link SwipeRefreshLayout#canChildScrollUp()} method 1215 | * behavior should implement this interface. 1216 | */ 1217 | public interface OnChildScrollUpCallback { 1218 | /** 1219 | * Callback that will be called when {@link SwipeRefreshLayout#canChildScrollUp()} method 1220 | * is called to allow the implementer to override its behavior. 1221 | * 1222 | * @param parent SwipeRefreshLayout that this callback is overriding. 1223 | * @param child The child view of SwipeRefreshLayout. 1224 | * 1225 | * @return Whether it is possible for the child view of parent layout to scroll up. 1226 | */ 1227 | boolean canChildScrollUp(SwipeRefreshLayout parent, @Nullable View child); 1228 | } 1229 | } 1230 | -------------------------------------------------------------------------------- /UnderstandingFitsSystemWindows/README.md: -------------------------------------------------------------------------------- 1 | # 深入理解 fitsSystemWindows 2 | 3 | ## 引入 4 | 5 | 透明状态栏是 Android apps 中经常需要实现的一种效果,很长一段时间,开发者都要为不同版本的适配而头痛,自 Android 4.4 KitKat 以来,系统中就已经提供修改状态栏(SystemUI)显示行为的选项了。其中带来的一个最令人困惑的问题就是 `fitsSystemWindows` 这个属性究竟该如何使用。 6 | 7 | 我们知道,给 Activity 设置透明状态栏十分简单,使用下面的 code snippet 就可以了: 8 | ```java 9 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 10 | Window window = getWindow(); 11 | window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); 12 | window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); 13 | window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 14 | | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); 15 | window.setStatusBarColor(Color.TRANSPARENT); 16 | } 17 | ``` 18 | 它相较于直接在 style.xml 中定义样式的好处就是不会有一个 scrim(不知道怎么翻译好,就是那个半透明的遮罩)。但只做这个工作就会导致下面这个情况: 19 |
20 |
21 | ![Figure 1.](https://github.com/unixzii/android-source-codes/raw/master/UnderstandingFitsSystemWindows/assets/1.png) 22 |
23 |
24 | 内容与状态栏区域重叠了!通常,大多数人会在布局中加一个: 25 | ```xml 26 | android:fitsSystemWindows="true" 27 | ``` 28 | 然后状态栏就显示正常了,但这还取决于布局,有的布局类直接加这个属性可能就不 work,尤其是 `CoordinatorLayout` 相关的布局,让人感觉这个属性很迷。确实,没有分析源码的时候我也很困惑。但经过简单的分析,一切都不是秘密。 29 | 30 | ## 初步分析 31 | 32 | 首先要想搞清楚这个属性的作用,我们就要到类中看看设置相关属性后到底会发生什么变化,于是找到 View 类的 `setFitsSystemWindows` 方法: 33 | ```java 34 | public void setFitsSystemWindows(boolean fitSystemWindows) { 35 | setFlags(fitSystemWindows ? FITS_SYSTEM_WINDOWS : 0, FITS_SYSTEM_WINDOWS); 36 | } 37 | ``` 38 | 额,好吧,其实经过继续跟踪之后,改变这个 flag 根本不会造成 View 的重新布局和 invalidate,所以这个属性一定是要在布局发生之前设置好的。但是看方法文档可以发现,这个属性与一个名为 `fitSystemWindows` 的方法密切相关,看一下: 39 | ```java 40 | protected boolean fitSystemWindows(Rect insets) { 41 | if ((mPrivateFlags3 & PFLAG3_APPLYING_INSETS) == 0) { 42 | if (insets == null) { 43 | // Null insets by definition have already been consumed. 44 | // This call cannot apply insets since there are none to apply, 45 | // so return false. 46 | return false; 47 | } 48 | // If we're not in the process of dispatching the newer apply insets call, 49 | // that means we're not in the compatibility path. Dispatch into the newer 50 | // apply insets path and take things from there. 51 | try { 52 | mPrivateFlags3 |= PFLAG3_FITTING_SYSTEM_WINDOWS; 53 | return dispatchApplyWindowInsets(new WindowInsets(insets)).isConsumed(); 54 | } finally { 55 | mPrivateFlags3 &= ~PFLAG3_FITTING_SYSTEM_WINDOWS; 56 | } 57 | } else { 58 | // We're being called from the newer apply insets path. 59 | // Perform the standard fallback behavior. 60 | return fitSystemWindowsInt(insets); 61 | } 62 | } 63 | ``` 64 | 这里面涉及一个转发修正的问题,我们这里先不去管它,直接看真正的实现`fitSystemWindowsInt`: 65 | ```java 66 | private boolean fitSystemWindowsInt(Rect insets) { 67 | if ((mViewFlags & FITS_SYSTEM_WINDOWS) == FITS_SYSTEM_WINDOWS) { 68 | mUserPaddingStart = UNDEFINED_PADDING; 69 | mUserPaddingEnd = UNDEFINED_PADDING; 70 | Rect localInsets = sThreadLocal.get(); 71 | if (localInsets == null) { 72 | localInsets = new Rect(); 73 | sThreadLocal.set(localInsets); 74 | } 75 | boolean res = computeFitSystemWindows(insets, localInsets); 76 | mUserPaddingLeftInitial = localInsets.left; 77 | mUserPaddingRightInitial = localInsets.right; 78 | internalSetPadding(localInsets.left, localInsets.top, 79 | localInsets.right, localInsets.bottom); 80 | return res; 81 | } 82 | return false; 83 | } 84 | ``` 85 | 可以看到,`fitsSystemWindows` 这个属性在这发挥了用武之地,如果设置了这个属性,那么就会有一个 padding 的设置,那这个 padding 来自哪,它是什么,现在还不得而知,这里就预计是状态栏的区域吧。padding 有什么用呢,ViewGroup 的一些子类在 measure 和 layout 的时候会获取 super 中与 padding 相关的成员变量来做布局上的调整,这就可以实现避开状态栏的问题了。 86 | 87 | 但是,上述方法的调用时机究竟是什么时候呢,我们可以通过 IDE 中强大的 **Find Usages** 来反向推导一下。最后发现它是由一个名为 `dispatchApplyWindowInsets` 的方法调用的,而且通过参数传了一个 `WindowInsets` 对象,这是什么鬼,我们后面就会讲到。在此之前我们断点打一下,看看这个方法是怎么被调用起来的: 88 |
89 |
90 | ![Figure 2.](https://github.com/unixzii/android-source-codes/raw/master/UnderstandingFitsSystemWindows/assets/2.png) 91 |
92 |
93 | 原来是 `ViewRootImpl` 发起的,这个类很重要,实现了很多 View 与 **WindowManager** 的交互,这里 `ViewRootImpl` somehow 拿到了一个 `WindowInsets` 对象,这个对象大家可以看看文档,就是包含了一些系统所占用的区域,**这些区域可以被消耗掉,并且消耗之后返回的是一个全新的对象,这句话请谨记**。 94 | 95 | 有关状态栏的高度包含在这个对象中无疑了,为了日后的扩展性,这个对象可能还会新增更多的 insets 类型,但就目前而言,仅限于状态栏和圆形手表上的一些特殊模式。 96 | 97 | 好,我们继续分析上面提到的那个方法: 98 | ```java 99 | public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) { 100 | try { 101 | mPrivateFlags3 |= PFLAG3_APPLYING_INSETS; 102 | if (mListenerInfo != null && mListenerInfo.mOnApplyWindowInsetsListener != null) { 103 | return mListenerInfo.mOnApplyWindowInsetsListener.onApplyWindowInsets(this, insets); 104 | } else { 105 | return onApplyWindowInsets(insets); 106 | } 107 | } finally { 108 | mPrivateFlags3 &= ~PFLAG3_APPLYING_INSETS; 109 | } 110 | } 111 | ``` 112 | 可以看到,这里不管设没设置 `fitsSystemWindows` 属性,都会激发一个 `onApplyWindowInsets` 回调,并且这个回调还可以通过 Listener 设置,有点意思。当然了,默认的回调实现的功能上面已经分析过了。 113 | 114 | 到现在为止貌似就可以解释为什么设置 `fitsSystemWindows` 属性后,绝大部分布局就可以避开状态栏了。但是不知道你有没有发现 `CoordinatorLayout` 会在状态栏下面画一个底色?`FrameLayout` 就没有这个特技,看来 `CoordinatorLayout` 的处理方式并非一个简单的 padding,肯定有自己的实现逻辑。 115 | 116 | 我们去它的源码找找看: 117 | ```java 118 | @Override 119 | public void setFitsSystemWindows(boolean fitSystemWindows) { 120 | super.setFitsSystemWindows(fitSystemWindows); 121 | setupForInsets(); 122 | } 123 | ``` 124 | 直奔 `setupForInsets`: 125 | ```java 126 | private void setupForInsets() { 127 | if (Build.VERSION.SDK_INT < 21) { 128 | return; 129 | } 130 | 131 | if (ViewCompat.getFitsSystemWindows(this)) { 132 | if (mApplyWindowInsetsListener == null) { 133 | mApplyWindowInsetsListener = 134 | new android.support.v4.view.OnApplyWindowInsetsListener() { 135 | @Override 136 | public WindowInsetsCompat onApplyWindowInsets(View v, 137 | WindowInsetsCompat insets) { 138 | return setWindowInsets(insets); 139 | } 140 | }; 141 | } 142 | // First apply the insets listener 143 | ViewCompat.setOnApplyWindowInsetsListener(this, mApplyWindowInsetsListener); 144 | 145 | // Now set the sys ui flags to enable us to lay out in the window insets 146 | setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE 147 | | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); 148 | } else { 149 | ViewCompat.setOnApplyWindowInsetsListener(this, null); 150 | } 151 | } 152 | ``` 153 | 这段代码可以说就是 View 需要自定义 `fitsSystemWindows` 行为的标准范式。核心的处理逻辑就在 `setWindowInsets` 这个方法中: 154 | ```java 155 | final WindowInsetsCompat setWindowInsets(WindowInsetsCompat insets) { 156 | if (!objectEquals(mLastInsets, insets)) { 157 | mLastInsets = insets; 158 | mDrawStatusBarBackground = insets != null && insets.getSystemWindowInsetTop() > 0; 159 | setWillNotDraw(!mDrawStatusBarBackground && getBackground() == null); 160 | 161 | // Now dispatch to the Behaviors 162 | insets = dispatchApplyWindowInsetsToBehaviors(insets); 163 | requestLayout(); 164 | } 165 | return insets; 166 | } 167 | ``` 168 | 知道状态栏下面的背景怎么来的了吧。 169 | 170 | 到这里理顺一下思路: 171 | `fitsSystemWindows` 与 `onApplyWindowInsets` 关系十分密切,后者将系统给出的 `WindowInsets` 派发给 View 让其根据前者这个属性来做自己的布局和绘制逻辑。 172 | 173 | ## 进阶应用 174 | 175 | 这一部分我们来讨论一下 `WindowInsets` 这个类,它有一个很重要的概念:consume。 176 | 177 | 这个概念重要到什么程度呢?如果你搞不懂 consume 和其 immutability,你自己的布局或者自定义 View 基本就爆炸了。 178 | 179 | 当一个 `View` 的 `dispatchApplyWindowInsets` 被调用时,它需要对 `WindowInsets` 对象作出响应,然后将处理的结果返回,处理结果基本就两种: 180 | 1. 你消耗了这个 insets,这时其它 View 收到的 insets 就是 0。 181 | 2. 你不想消耗 insets,那么其它 View 将继续响应一开始的 insets 值。 182 | 183 | 还有一种特殊的情况:你返回了消耗过的 insets,但保存了一份原始 insets 引用,这时这个视图的**兄弟视图和其兄弟视图的子视图**就会收到值为 0 的 insets,而这个视图可以根据情况让它的子视图收到一个原始未消耗的 insets,这也是 `DrawerLayout` 所做的事情,想搞清它这么做的原因,本文就讲不完了,我后期可能会再开一篇文章分析。 184 | 185 | 讲这么多有没有 🌰 呢?当然有,先看下面的效果: 186 |
187 |
188 | ![Figure 3.](https://github.com/unixzii/android-source-codes/raw/master/UnderstandingFitsSystemWindows/assets/3.png) 189 |
190 |
191 | 显然,这是 `CoordinatorLayout` 配合 `CollapsingToolbarLayout` 实现的,但是这里给 `CoordinatorLayout` 加 `fitsSystemWindows` 就不灵了,它会吃掉状态栏的位置,然后画个背景色,我们的图片就不能垫在状态栏底下了,我通过分析各个类(这块真是花了很多时间),发现 `AppBarLayout` 也实现了 `fitsSystemWindows` 的自定义行为(毕竟放在它里面的 `CollapsingToolbarLayout` 有一个 `statusBarScrim` 属性),但是给它加上这个属性以后,图片依然会被挤下去。 192 | 193 | 怎么办呢?就在我扫荡 `CollapsingToolbarLayout` 的源码的时候发现了下面这段逻辑: 194 | ```java 195 | @Override 196 | protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 197 | super.onLayout(changed, left, top, right, bottom); 198 | 199 | if (mLastInsets != null) { 200 | // Shift down any views which are not set to fit system windows 201 | final int insetTop = mLastInsets.getSystemWindowInsetTop(); 202 | for (int i = 0, z = getChildCount(); i < z; i++) { 203 | final View child = getChildAt(i); 204 | if (!ViewCompat.getFitsSystemWindows(child)) { 205 | if (child.getTop() < insetTop) { 206 | // If the child isn't set to fit system windows but is drawing within 207 | // the inset offset it down 208 | ViewCompat.offsetTopAndBottom(child, insetTop); 209 | } 210 | } 211 | } 212 | } 213 | 214 | ... 215 | } 216 | ``` 217 | 这就是说,如果 `CollapsingToolbarLayout` 的某个子视图开启了 `fitsSystemWindows` 这个属性,那么它就会被填满父视图,否则,它就会被下移 top inset 的距离。那这个问题的解决方法就很明显了,直接给 `ImageView` 加一个 `fitsSystemWindows`,完事了。 218 | 219 | 不得不感叹 Android 设计的精巧。 220 | 221 | 在我这么做之前,我看了市面上 99% 的 app 都是用了很“暴力”的方式解决,强行算状态栏高度,然后设置 margin,很不优雅,实际上 Android 已经为我们考虑地十分周全了,很多效果基本都可以用原生的方式实现,就看你会不会做了,如何发现这些小技巧,还是要靠源码分析。 222 | 223 | 那么最后给大家留一个小小的 homework,可否给我们的图片在状态栏的位置加一个 scrim?(hint:可以参考 `NavigationView`) 224 | 225 | ## 推广信息 226 | 227 | 如果你对我的 Android 源码分析系列文章感兴趣,可以点个 star 哦,我会持续不定期更新文章。 -------------------------------------------------------------------------------- /UnderstandingFitsSystemWindows/assets/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixzii/android-source-codes/aa308a5aac2189c03964c93579837dd14c65e0c9/UnderstandingFitsSystemWindows/assets/1.png -------------------------------------------------------------------------------- /UnderstandingFitsSystemWindows/assets/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixzii/android-source-codes/aa308a5aac2189c03964c93579837dd14c65e0c9/UnderstandingFitsSystemWindows/assets/2.png -------------------------------------------------------------------------------- /UnderstandingFitsSystemWindows/assets/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixzii/android-source-codes/aa308a5aac2189c03964c93579837dd14c65e0c9/UnderstandingFitsSystemWindows/assets/3.png --------------------------------------------------------------------------------