Skip to content

Commit c83b698

Browse files
committed
feat(ui): add LazyColumnComponent for virtualized vertical lists with scrolling support
1 parent fba6d3b commit c83b698

3 files changed

Lines changed: 215 additions & 4 deletions

File tree

module.ui/TODO.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
| **Box** (≡ Stack) | ✅ 已实现 | 层叠布局,子组件按声明顺序从底到顶重叠 |
1414
| **Grid** | ✅ 已实现 | 网格布局,指定列数,自动换行 |
1515
| **Scrollable** (≡ Scroll) | ✅ 已实现 | 可滚动容器,maxHeight 超限时裁剪 + 滚动条 + 拖拽 |
16-
| **Flex** | ✅ 已实现 | 弹性布局,子组件按 flexGrow 权重分配主轴剩余空间 |
17-
| **List** / **LazyColumn** | ❎ 未实现 | 虚拟化长列表,仅渲染可见区域 |
16+
| **Flex** | ✅ 已实现 | 弹性布局,子组件按 flexGrow 权重分配主轴剩余空间 |
17+
| **List** / **LazyColumn** | ✅ 已实现 | 虚拟化长列表,固定项高,仅渲染可见项,滚轮+滚动条拖拽 |
1818
| **Tabs** | ❎ 未实现 | 标签页切换容器 |
1919
| **Swiper** | ❎ 未实现 | 轮播/滑动切换容器 |
2020
| **SideBarContainer** | ❎ 未实现 | 侧边栏抽屉容器 |
@@ -110,8 +110,8 @@
110110
## 统计
111111

112112
- 总计参考组件:**52**
113-
- 已实现:**27**(含引擎核心 + 状态管理 + 修饰符 + Select/Dropdown + Flex)
114-
- 未实现:**25**
113+
- 已实现:**28**(含引擎核心 + 状态管理 + 修饰符 + Select/Dropdown + Flex + LazyColumn
114+
- 未实现:**24**
115115

116116
### 近期大更新
117117

module.ui/src/main/java/dev/anvilcraft/lib/v2/ui/UIScope.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import dev.anvilcraft.lib.v2.ui.component.FlexScope;
1010
import dev.anvilcraft.lib.v2.ui.component.GridComponent;
1111
import dev.anvilcraft.lib.v2.ui.component.ImageComponent;
12+
import dev.anvilcraft.lib.v2.ui.component.LazyColumnComponent;
1213
import dev.anvilcraft.lib.v2.ui.component.RowComponent;
1314
import dev.anvilcraft.lib.v2.ui.component.ScrollableComponent;
1415
import dev.anvilcraft.lib.v2.ui.component.SliderComponent;
@@ -314,4 +315,20 @@ public ScrollableComponent Scrollable(float maxHeight, Consumer<ScrollableScope>
314315
Composition.current().emit(c);
315316
return c;
316317
}
318+
319+
/**
320+
* 创建虚拟化纵向列表。仅渲染可见区域,支持滚轮和滚动条拖拽。
321+
*
322+
* @param itemHeight 单项高度(像素)
323+
* @param maxHeight 列表最大可见高度
324+
* @param items 数据源列表
325+
* @param builder 列表项构建函数
326+
*/
327+
public <T> LazyColumnComponent LazyColumn(float itemHeight, float maxHeight,
328+
List<T> items, LazyColumnComponent.ItemBuilder<T> builder) {
329+
LazyColumnComponent c = new LazyColumnComponent(Modifier.NONE, itemHeight, maxHeight, items, builder);
330+
this.addChild(c);
331+
Composition.current().emit(c);
332+
return c;
333+
}
317334
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package dev.anvilcraft.lib.v2.ui.component;
2+
3+
import dev.anvilcraft.lib.v2.ui.Constraints;
4+
import dev.anvilcraft.lib.v2.ui.LayoutRect;
5+
import dev.anvilcraft.lib.v2.ui.MeasuredSize;
6+
import dev.anvilcraft.lib.v2.ui.Modifier;
7+
import dev.anvilcraft.lib.v2.ui.UIComponent;
8+
import lombok.Getter;
9+
import lombok.Setter;
10+
import lombok.experimental.Accessors;
11+
import net.minecraft.client.gui.GuiGraphicsExtractor;
12+
import net.minecraft.util.Mth;
13+
14+
import java.util.ArrayList;
15+
import java.util.Collections;
16+
import java.util.List;
17+
18+
/**
19+
* 虚拟化纵向列表。仅渲染可见区域的项,支持滚动。
20+
* 每项固定高度,通过 {@code itemHeight} 指定。
21+
*/
22+
@Accessors(fluent = true)
23+
@SuppressWarnings({"unused", "UnusedReturnValue"})
24+
public class LazyColumnComponent implements UIComponent {
25+
26+
private static final int SCROLLBAR_COLOR = 0xFF888888;
27+
private static final int SCROLLBAR_BG = 0xFF333333;
28+
private static final int SCROLLBAR_W = 4;
29+
30+
@Getter @Setter
31+
private Modifier modifier;
32+
private final float itemHeight;
33+
private final float maxHeight;
34+
private final List<?> items;
35+
private final ItemBuilder<?> builder;
36+
private List<UIComponent> children = Collections.emptyList();
37+
private float scrollOffset;
38+
39+
@Getter
40+
private boolean scrollbarDragging;
41+
private float dragAnchorY;
42+
43+
private float x, y, width, height;
44+
45+
/** 列表项构建函数接口。 */
46+
@FunctionalInterface
47+
public interface ItemBuilder<T> {
48+
UIComponent build(T item);
49+
}
50+
51+
public <T> LazyColumnComponent(Modifier modifier, float itemHeight, float maxHeight,
52+
List<T> items, ItemBuilder<T> builder) {
53+
this.modifier = modifier;
54+
this.itemHeight = itemHeight;
55+
this.maxHeight = maxHeight;
56+
this.items = List.copyOf(items);
57+
this.builder = builder;
58+
}
59+
60+
@Override
61+
public List<UIComponent> children() { return this.children; }
62+
63+
@Override
64+
public MeasuredSize measure(Constraints constraints) {
65+
float contentH = this.items.size() * this.itemHeight;
66+
float displayH = Math.min(contentH, this.maxHeight);
67+
return MeasuredSize.of(constraints.constrainWidth(200), constraints.constrainHeight(displayH));
68+
}
69+
70+
@Override
71+
public void layout(float x, float y, float width, float height) {
72+
this.x = x; this.y = y; this.width = width; this.height = height;
73+
rebuildChildren();
74+
float maxScroll = Math.max(0, this.items.size() * this.itemHeight - height);
75+
this.scrollOffset = Mth.clamp(this.scrollOffset, 0, maxScroll);
76+
}
77+
78+
@SuppressWarnings({"unchecked", "rawtypes"})
79+
private void rebuildChildren() {
80+
int first = (int) (this.scrollOffset / this.itemHeight);
81+
int visible = Math.min((int) (this.height / this.itemHeight) + 1, this.items.size() - first);
82+
List<UIComponent> newChildren = new ArrayList<>(visible);
83+
for (int i = 0; i < visible; i++) {
84+
Object item = this.items.get(first + i);
85+
newChildren.add(((ItemBuilder) this.builder).build(item));
86+
}
87+
this.children = newChildren;
88+
// Position each child
89+
for (int i = 0; i < visible; i++) {
90+
UIComponent child = newChildren.get(i);
91+
float childY = this.y - (this.scrollOffset % this.itemHeight) + i * this.itemHeight;
92+
child.measure(Constraints.NONE);
93+
child.layout(this.x, childY, this.width, this.itemHeight);
94+
}
95+
}
96+
97+
@Override
98+
public void extractRenderState(GuiGraphicsExtractor extractor) {
99+
int ix = (int) this.x, iy = (int) this.y, iw = (int) this.width, ih = (int) this.height;
100+
extractor.enableScissor(ix, iy, ix + iw, iy + ih);
101+
102+
for (UIComponent child : this.children) {
103+
child.extractRenderState(extractor);
104+
}
105+
106+
extractor.disableScissor();
107+
108+
// 滚动条
109+
float contentH = this.items.size() * this.itemHeight;
110+
if (contentH > this.height) {
111+
float bh = Math.max(16, this.height * this.height / contentH);
112+
float maxScroll = contentH - this.height;
113+
float by = this.y + (this.scrollOffset / maxScroll) * (this.height - bh);
114+
int bx = (int) (this.x + this.width - SCROLLBAR_W - 1);
115+
extractor.fill(bx, iy, bx + SCROLLBAR_W, iy + ih, SCROLLBAR_BG);
116+
extractor.fill(bx, (int) by, bx + SCROLLBAR_W, (int) (by + bh), SCROLLBAR_COLOR);
117+
}
118+
}
119+
120+
// ── 滚动 ──
121+
122+
@Override
123+
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
124+
if (!this.hitRect().contains((float) mouseX, (float) mouseY)) return false;
125+
this.scrollOffset = Mth.clamp(
126+
this.scrollOffset + (float) (-scrollY * 20),
127+
0, Math.max(0, this.items.size() * this.itemHeight - this.height)
128+
);
129+
return true;
130+
}
131+
132+
public boolean isOnScrollbar(float mx, float my) {
133+
float contentH = this.items.size() * this.itemHeight;
134+
if (contentH <= this.height) return false;
135+
float bh = Math.max(16, this.height * this.height / contentH);
136+
float maxScroll = contentH - this.height;
137+
float by = this.y + (this.scrollOffset / maxScroll) * (this.height - bh);
138+
int bx = (int) (this.x + this.width - SCROLLBAR_W - 1);
139+
return mx >= bx && mx < bx + SCROLLBAR_W && my >= by && my < by + bh;
140+
}
141+
142+
public void startScrollbarDrag(float my) {
143+
this.scrollbarDragging = true;
144+
float contentH = this.items.size() * this.itemHeight;
145+
float bh = Math.max(16, this.height * this.height / contentH);
146+
float maxScroll = contentH - this.height;
147+
float by = this.y + (this.scrollOffset / maxScroll) * (this.height - bh);
148+
this.dragAnchorY = my - by;
149+
}
150+
151+
public void onScrollbarDrag(float my) {
152+
if (!this.scrollbarDragging) return;
153+
float contentH = this.items.size() * this.itemHeight;
154+
float bh = Math.max(16, this.height * this.height / contentH);
155+
float maxScroll = contentH - this.height;
156+
float newBarY = my - this.dragAnchorY;
157+
float ratio = Mth.clamp((newBarY - this.y) / (this.height - bh), 0f, 1f);
158+
this.scrollOffset = ratio * maxScroll;
159+
}
160+
161+
public void stopScrollbarDrag() { this.scrollbarDragging = false; }
162+
163+
public LayoutRect hitRect() {
164+
return LayoutRect.of(this.x, this.y, this.width, this.height);
165+
}
166+
167+
@Override
168+
public boolean mouseClicked(net.minecraft.client.input.MouseButtonEvent event, boolean isDouble) {
169+
if (event.button() != 0) return false;
170+
var mc = net.minecraft.client.Minecraft.getInstance();
171+
int mx = (int) mc.mouseHandler.getScaledXPos(mc.getWindow());
172+
int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow());
173+
if (this.isOnScrollbar(mx, my)) { this.startScrollbarDrag(my); return true; }
174+
for (UIComponent child : this.children) {
175+
if (child.mouseClicked(event, isDouble)) return true;
176+
}
177+
return false;
178+
}
179+
180+
@Override
181+
public boolean mouseDragged(net.minecraft.client.input.MouseButtonEvent event, double dx, double dy) {
182+
if (!this.scrollbarDragging) return false;
183+
var mc = net.minecraft.client.Minecraft.getInstance();
184+
int my = (int) mc.mouseHandler.getScaledYPos(mc.getWindow());
185+
this.onScrollbarDrag(my);
186+
return true;
187+
}
188+
189+
@Override
190+
public boolean mouseReleased(net.minecraft.client.input.MouseButtonEvent event) {
191+
this.stopScrollbarDrag();
192+
return false;
193+
}
194+
}

0 commit comments

Comments
 (0)