WinForm自定义滚动条:GDI+全接管绘制与交互实现

发布时间:2026/10/9 13:01:54

WinForm自定义滚动条:GDI+全接管绘制与交互实现 简介本资源是一份面向C# WinForm开发者的自定义滚动条控件实践项目聚焦解决原生VScrollBar/HScrollBar外观单一、难以适配UI主题的问题。通过继承重写OnPaint方法完整实现了拖块颜色、轨道颜色的自由配置并支持线条与矩形两种轨道样式切换适用于需要高定制化界面的桌面应用开发场景。压缩包共34个文件含11个核心C#源码文件如MyVScrollBar.cs、Form1.cs等、3个可执行exe用于快速验证效果、3个resx资源文件管理本地化文本、1个csproj与1个sln工程文件结构完整可直接编译运行整体仅66KB轻量易集成。已有703人学习下载提供从控件类定义、自绘制逻辑、事件响应到窗体集成的全链路代码实现包含详细注释与标准WinForm项目目录组织是理解GDI自绘控件机制的优质入门范例。1. WinForm 自定义滚动条为什么改个拖块颜色要重写整个控件你试过在 WinForm 里把VScrollBar的拖块thumb改成深蓝色、轨道track改成浅灰细线结果发现.BackColor不生效、.ForeColor没反应、Paint事件里画出来又卡顿掉帧这不是你代码写错了——是 WinForm 原生滚动条压根没暴露这些视觉属性。它用的是 Windows 系统主题渲染UxTheme所有颜色、圆角、阴影都由 OS 统一控制开发者只能“请求”样式不能“定义”样式。而标题里说的「可修改拖块颜色与轨道颜色」「轨道支持线条/矩形两种样式」本质是在绕过系统渲染层用 GDI 完全接管绘制逻辑——不是简单改个属性而是把滚动条变成一个“会响应鼠标、能计算位置、懂拖拽边界、还要抗闪烁”的自绘控件。适合正在做 WinForm 界面美化、医疗/工业类软件 UI 统一化、或需要适配高对比度模式的开发者。如果你的项目还在用PanelLabel手动模拟滚动那这篇就是你的后悔药如果你刚被设计师甩来一份带渐变拖块和虚线轨道的 UI 稿那这篇就是你今晚不用加班的底气。2. 从零手写 ScrollBarEx继承 Control 而非 ScrollBar 的底层逻辑WinForm 中定制滚动条有两条路一是继承VScrollBar/HScrollBar并重写WndProc拦截WM_PAINT但系统仍会偷偷画一层底纹导致双绘制造成模糊二是彻底放弃原生控件继承Control从头实现——这是唯一能 100% 控制拖块颜色、轨道形状、鼠标热区、甚至支持圆角拖块和虚线轨道的方案。我选后者原因很实际原生ScrollBar的ThumbRect计算逻辑藏在私有方法里反编译看得到但不能调用ScrollEventArgs只通知值变化不告诉你鼠标是否悬停在拖块上系统 DPI 缩放时原生控件的ClientRectangle和Graphics.ScaleTransform行为不一致自绘反而更可控。所以ScrollBarEx的骨架必须自己建它要响应MouseDown/MouseMove/MouseUp实现拖拽要监听Wheel事件支持滚轮要重写OnPaint用 GDI 画出你指定的拖块和轨道还要暴露ThumbColor、TrackStyleLines/Rectangle、TrackColor三个核心属性。下面是最小可行版本的类声明和初始化逻辑public class ScrollBarEx : Control { private const int THUMB_MIN_HEIGHT 16; // 拖块最小高度防点击失效 private const int TRACK_PADDING 4; // 轨道内边距留出拖块活动空间 private bool _isDragging false; private Point _dragStartPoint; private int _dragStartValue; // 公开属性拖块颜色、轨道颜色、轨道样式Lines 或 Rectangle public Color ThumbColor { get; set; } Color.FromArgb(74, 144, 226); public Color TrackColor { get; set; } Color.FromArgb(230, 230, 230); public TrackStyle TrackStyle { get; set; } TrackStyle.Rectangle; // 构造函数禁用双缓冲默认关闭我们手动开启 public ScrollBarEx() { SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.AllPaintingInWmPaint, true); UpdateStyles(); // 默认尺寸垂直滚动条宽16px水平高16px if (this is VScrollBarEx) this.Width 16; else this.Height 16; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); DrawScrollBar(e.Graphics); } private void DrawScrollBar(Graphics g) { // 步骤1计算轨道可用区域扣除两端箭头后的真实滑动区 Rectangle trackRect GetTrackRectangle(); // 步骤2计算拖块当前位置映射 Value 到像素坐标 Rectangle thumbRect GetThumbRectangle(trackRect); // 步骤3按 TrackStyle 绘制轨道线条 or 矩形 DrawTrack(g, trackRect); // 步骤4绘制拖块带圆角、阴影、悬停态 DrawThumb(g, thumbRect); } }注意这里SetStyle开启OptimizedDoubleBuffer是关键——WinForm 默认双缓冲只对Paint事件生效但滚动条频繁重绘时若不强制会出现明显撕裂。AllPaintingInWmPaint则确保所有绘制都在WM_PAINT消息中完成避免Invalidate()后闪屏。GetTrackRectangle()的实现必须考虑箭头按钮Arrow Buttons占位。原生滚动条两端各有一个 16px 高/宽的箭头区但自绘时我们通常隐藏箭头用ButtonStyle.None所以轨道区域直接取ClientRectangle减去TRACK_PADDING。而GetThumbRectangle()的核心是把Value映射到像素若Minimum0,Maximum100,LargeChange10则拖块长度 (LargeChange / (Maximum - Minimum 1)) * trackHeight拖块 Y 坐标 trackTop (Value - Minimum) / (Maximum - Minimum) * (trackHeight - thumbHeight)。这个公式必须手写因为ScrollBar的SmallChange/LargeChange在自绘中只是逻辑参数不参与像素计算。2.1 轨道绘制线条 vs 矩形的 GDI 实现差异轨道样式TrackStyle不是换张图片那么简单——线条模式要处理抗锯齿、虚实线宽、端点样式矩形模式要考虑内边距、圆角、渐变填充。二者在DrawTrack()中完全分支private void DrawTrack(Graphics g, Rectangle trackRect) { switch (TrackStyle) { case TrackStyle.Lines: // 绘制两条平行细线上轨线 下轨线间距 trackRect.Height - 2 using (var pen new Pen(TrackColor, 1f)) { pen.Alignment PenAlignment.Center; pen.DashStyle DashStyle.Solid; // 上轨线y trackRect.Top 1 g.DrawLine(pen, trackRect.Left 1, trackRect.Top 1, trackRect.Right - 1, trackRect.Top 1); // 下轨线y trackRect.Bottom - 1 g.DrawLine(pen, trackRect.Left 1, trackRect.Bottom - 1, trackRect.Right - 1, trackRect.Bottom - 1); } break; case TrackStyle.Rectangle: // 绘制带圆角的轨道矩形内边距 2px圆角半径 2 using (var path GraphicsPathHelper.CreateRoundRect( trackRect.X 2, trackRect.Y 2, trackRect.Width - 4, trackRect.Height - 4, 2)) using (var brush new SolidBrush(TrackColor)) { g.FillPath(brush, path); } break; } }参数说明PenAlignment.Center确保 1px 线条居中绘制避免偏移DashStyle.Solid是默认但预留了扩展虚线轨道的能力如DashStyle.DashDotGraphicsPathHelper.CreateRoundRect是一个静态辅助方法封装AddArc四角圆角逻辑代码见文末附录矩形模式中-4是左右/上下各减 2px 内边距让拖块有 2px 活动间隙防止贴边卡死。为什么不用ControlPaint.DrawBorder因为它画的是标准 Windows 边框单色、直角、无圆角且无法控制线条粗细和端点样式。而设计师给的 UI 稿里轨道常是#E6E6E6的 0.5px 细线或带2px圆角的浅灰矩形——这些必须用原始 GDI 控制。2.2 拖块绘制抗锯齿、悬停反馈与 DPI 自适应拖块thumb是交互焦点它的绘制质量直接影响用户感知。原生控件拖块是纯色矩形但我们至少要做到圆角4px 内阴影模拟浮起感鼠标悬停时亮度提升 20%ControlPaint.Light()不够准需手动计算DPI 缩放时保持 1px 边框不糊用g.SmoothingMode SmoothingMode.AntiAliasg.TextRenderingHint TextRenderingHint.ClearTypeGridFit。private void DrawThumb(Graphics g, Rectangle thumbRect) { // 步骤1计算悬停态颜色仅当鼠标在拖块内时 Color drawThumbColor _isHovering ? ControlPaint.Light(ThumbColor, 0.2f) : ThumbColor; // 步骤2创建圆角路径4px 半径 using (var path GraphicsPathHelper.CreateRoundRect( thumbRect.X, thumbRect.Y, thumbRect.Width, thumbRect.Height, 4)) { // 步骤3用线性渐变填充顶部亮、底部暗模拟立体感 using (var brush new LinearGradientBrush( thumbRect, Color.FromArgb(255, drawThumbColor.R, drawThumbColor.G, drawThumbColor.B), Color.FromArgb(200, drawThumbColor.R * 0.8f, drawThumbColor.G * 0.8f, drawThumbColor.B * 0.8f), LinearGradientMode.Vertical)) { g.FillPath(brush, path); } // 步骤4绘制 1px 外边框抗锯齿 using (var pen new Pen(Color.FromArgb(100, 0, 0, 0), 1f)) { g.DrawPath(pen, path); } } }关键细节ControlPaint.Light()对深色如#4A90E2提亮后容易发灰所以改用Color.FromArgb(255, r*1.2, g*1.2, b*1.2)但需钳制在 0~255LinearGradientBrush的Vertical模式让拖块顶部更亮符合光照常识SmoothingMode.AntiAlias必须在OnPaint开头设置否则圆角边缘锯齿明显DPI 缩放时thumbRect已由 WinForm 自动缩放但GraphicsPath的AddArc参数需用DpiX/DpiY校正——实际测试中只要Control的AutoScaleMode AutoScaleMode.DpiGDI 会自动适配无需手动乘除。3. 鼠标交互与拖拽逻辑为什么拖块会“跳变”滚动条最易翻车的环节不是绘制而是交互——拖块跟着鼠标跑但松手后值突变、拖拽时卡顿、滚轮滚动不连续。根源在于原生ScrollBar的Value是离散整数而自绘拖块位置是连续像素二者映射必须严格守恒。比如Maximum100拖块从Y10拖到Y110轨道高100px那么Value应该从0线性变为100而不是四舍五入成1或10。3.1 拖拽状态机MouseDown → MouseMove → MouseUp 的三段校验ScrollBarEx的拖拽不是简单记录鼠标差值而是构建状态机protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); Rectangle thumbRect GetThumbRectangle(GetTrackRectangle()); if (thumbRect.Contains(e.Location)) { _isDragging true; _dragStartPoint e.Location; _dragStartValue Value; // 记录起始值用于相对计算 Capture true; // 关键防止鼠标移出控件时丢失消息 Cursor Cursors.SizeNS; } else { // 点击轨道空白处跳转到点击位置对应值 int newValue CalculateValueFromPosition(e.Y); if (newValue ! Value) { Value newValue; OnScroll(ScrollEventType.ThumbPosition, newValue); } } } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_isDragging) { Rectangle trackRect GetTrackRectangle(); int delta e.Y - _dragStartPoint.Y; // 垂直滚动条用 Y int newValue _dragStartValue (int)Math.Round( (double)delta / (trackRect.Height - GetThumbHeight()) * (Maximum - Minimum)); newValue Math.Max(Minimum, Math.Min(Maximum, newValue)); if (newValue ! Value) { Value newValue; OnScroll(ScrollEventType.ThumbPosition, newValue); } } } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); if (_isDragging) { _isDragging false; Capture false; Cursor Cursors.Default; } }血泪经验Capture true是防丢拖拽的救命开关。没有它鼠标快速拖出控件区域时MouseMove会停止触发拖块就“粘”在半空CalculateValueFromPosition()必须用(e.Y - trackRect.Top)而非e.Y否则 DPI 缩放后计算失准Math.Round()代替Math.Floor()避免向下取整导致拖拽迟滞。3.2 滚轮与键盘支持被忽略的无障碍访问用户不会只用鼠标——滚轮滚动、PageUp/PageDown、Home/End 键都必须响应。原生ScrollBar自动处理但自绘控件要手动补全protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); int delta e.Delta 0 ? -SmallChange : SmallChange; // 滚轮向上 值减小内容上移 int newValue Math.Max(Minimum, Math.Min(Maximum, Value delta)); if (newValue ! Value) { Value newValue; OnScroll(ScrollEventType.ThumbPosition, newValue); } } protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { switch (keyData) { case Keys.PageUp: Value Math.Max(Minimum, Value - LargeChange); OnScroll(ScrollEventType.First, Value); return true; case Keys.PageDown: Value Math.Min(Maximum, Value LargeChange); OnScroll(ScrollEventType.Last, Value); return true; case Keys.Home: Value Minimum; OnScroll(ScrollEventType.First, Value); return true; case Keys.End: Value Maximum; OnScroll(ScrollEventType.Last, Value); return true; default: return base.ProcessCmdKey(ref msg, keyData); } }提示OnScroll()是自定义事件需声明public event ScrollEventHandler Scroll;并在OnScroll()方法中触发。ScrollEventType.ThumbPosition表示拖拽中First/Last表示快捷键跳转——这能让宿主控件如RichTextBox区分操作来源决定是否重绘全文或局部。4. 避坑指南WinForm 自绘滚动条的 5 个真实翻车现场做这个控件时我在三个项目里踩过这些坑每一条都导致过客户演示当场崩溃。列在这里不是为了吓人是让你少花 8 小时 debug。4.1 现象拖块在高 DPI125%/150%下变形、圆角消失原因GraphicsPath.AddArc()的半径参数未随 DPI 缩放1px 在 150% DPI 下变成 1.5pxGDI 截断为 1px 导致圆角失效。解决不用硬编码4改用Convert.ToInt32(4 * CreateGraphics().DpiX / 96f)获取当前 DPI 缩放比或直接设AutoScaleMode AutoScaleMode.Dpi并在OnPaint中用g.Transform new Matrix()校正更稳妥。4.2 现象鼠标快速拖拽时拖块“瞬移”松手后值跳变原因OnMouseMove中delta计算用了e.Y绝对坐标但GetThumbRectangle()返回的thumbRect是基于ClientRectangle的相对坐标二者坐标系不统一。解决所有坐标运算统一用e.Location已转换为控件坐标或在OnMouseDown中记录thumbRect.Top作为基准后续delta用e.Y - _dragStartPoint.Y。4.3 现象启用DoubleBuffered true后拖块拖拽时出现残影原因ControlStyles.OptimizedDoubleBuffer和DoubleBuffered true双重开启导致缓冲区错乱或OnPaint中未调用base.OnPaint(e)清空背景。解决只用SetStyle(ControlStyles.OptimizedDoubleBuffer, true)删掉DoubleBuffered trueOnPaint第一行加g.Clear(BackColor)强制清屏尤其当BackColor不是纯色时。4.4 现象TrackStyle Lines时拖块拖到轨道顶端/底端卡住不动原因GetThumbRectangle()计算thumbRect.Y时未处理边界——当Value Minimum时thumbRect.Y可能小于trackRect.TopGDI 绘制时裁剪导致不可见。解决在GetThumbRectangle()返回前加校验thumbRect.Y Math.Max(trackRect.Top, Math.Min(trackRect.Bottom - thumbRect.Height, thumbRect.Y));。4.5 现象嵌入Panel后滚动条响应变慢OnPaint被高频调用原因Panel默认AutoScroll true它会拦截WM_HSCROLL/WM_VSCROLL消息并重绘自身与ScrollBarEx的OnPaint冲突。解决宿主Panel设AutoScroll false用ScrollBarEx的Scroll事件手动控制Panel.AutoScrollPosition或改用TableLayoutPanel布局避免嵌套滚动。5. 进阶技巧用 ShaderEffect 模拟玻璃质感拖块与动态轨道做到上面你已经能交付一个专业级自定义滚动条。但如果想让它真正惊艳——比如拖块带毛玻璃模糊、轨道随滚动进度变色、拖块悬停时浮现微光——就得引入System.Drawing.Drawing2D的进阶能力。这里不讲 WPF 或 DirectX纯 WinForm GDI 可行方案。5.1 拖块玻璃效果用GraphicsPathBlur模拟WinForm 没有原生高斯模糊但可用多层半透明叠加模拟。核心思路画一个比拖块大 4px 的圆角矩形用Color.FromArgb(30, 255, 255, 255)填充再画原拖块形成“发光”感private void DrawGlassThumb(Graphics g, Rectangle thumbRect) { // 步骤1画模糊外晕4px 扩展低透明度白 Rectangle glowRect new Rectangle( thumbRect.X - 4, thumbRect.Y - 4, thumbRect.Width 8, thumbRect.Height 8); using (var path GraphicsPathHelper.CreateRoundRect( glowRect.X, glowRect.Y, glowRect.Width, glowRect.Height, 8)) using (var brush new SolidBrush(Color.FromArgb(30, 255, 255, 255))) { g.FillPath(brush, path); } // 步骤2画主体拖块带渐变 DrawThumb(g, thumbRect); // 复用前面的 DrawThumb }效果对比普通拖块是“平面色块”玻璃拖块是“悬浮光体”。实测在 i5-8250U 上帧率无损因FillPath比DrawImage更轻量。5.2 动态轨道根据滚动位置切换线条/矩形样式设计师常要求“滚动到 50% 时轨道从线条变为矩形”。这需要监听Value变化并触发重绘private int _lastValue 0; public int Value { get _value; set { if (_value ! value) { _value Math.Max(Minimum, Math.Min(Maximum, value)); // 动态切换轨道样式0~49% 线条50~100% 矩形 if (_value (Maximum - Minimum) / 2) TrackStyle TrackStyle.Lines; else TrackStyle TrackStyle.Rectangle; _lastValue _value; Invalidate(); // 强制重绘 } } }性能提示Invalidate()比Refresh()更高效它只标记区域为“脏”不立即重绘若滚动频繁可加if (Math.Abs(_value - _lastValue) 2) Invalidate();限频。5.3 最终落地检查表交付前必验的 7 项检查项测试方法通过标准DPI 适配设置系统缩放为 125%、150%拖拽拖块拖块大小、圆角、文字清晰无锯齿键盘导航Tab 切入滚动条按 PageUp/PageDown/Home/End值准确跳转Scroll事件触发滚轮精度滚轮单次滚动观察Value变化步长步长 SmallChange无跳变悬停反馈鼠标悬停拖块 1 秒颜色提亮无闪烁边界容错Minimum0,Maximum1,Value0时拖拽拖块不消失Value不越界嵌套兼容放入GroupBox/TabControl/SplitContainer无裁剪、无错位、事件正常内存泄漏连续拖拽 10 分钟用 Visual Studio 诊断工具监控Graphics/Brush/Pen对象数稳定不增长我习惯在Dispose()中显式释放GraphicsPath和Brush虽然 .NET GC 会回收但大型工业软件运行 7×24 小时这点谨慎值得。最后把ScrollBarEx编译成 DLL用Toolbox→Choose Items添加到 VS 工具箱——从此你的 WinForm 项目再不用忍受那个灰扑扑的原生滚动条。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/9 12:56:54

pstack-claude:Claude本地化接入的三层解耦实践

1. 项目概述:pstack-claude 是什么,它解决的是哪类开发者的实际痛点?pstack-claude 这个名字乍看像一个工具组合词,但拆开来看,它其实指向一个非常具体、高频且长期被忽视的工程实践场景:在本地开发环境中&…

2026/10/9 18:03:28

项目风险管理实战指南:从风险识别、评估到应对策略

做项目经理这几年,我吃过最大的亏,往往不是技术难题,而是那些“根本没想到会出事”的环节。印象最深的一次,某系统集成项目本来一切顺利,却在临上线前一周,因为供应商把关键设备交期推迟了一个月&#xff0…

2026/10/9 18:03:28

论文重复率过高怎么办?汇写降重降AIGC一站式帮你顺利过审

到毕业季,总有一批同学因为论文重复率过高而延毕。辛苦写了几个月的论文,提交查重后发现重复率远超学校要求,只能连夜修改;好不容易降完重,学校又开始查AIGC率,AI生成特征过高同样过不了关。面对越来越严格…

2026/10/9 18:03:28

Modbus调试工具实战指南:快速定位工控通讯故障

1. 项目概述:为什么一个工控调试工具能被叫作“救急神器”“Modbus调试救急神器:良友工控助手真香体验”——这个标题里,“救急神器”四个字不是营销话术,而是现场工程师脱口而出的真实反馈。我干工控调试这行十二年,跑…

2026/10/9 18:03:28

dnSpy-net472:.NET Framework 4.7.2 DLL反编译、编辑与热调试实战指南

简介:本资源为dnSpy反编译调试工具的.NET Framework 4.7.2适配版,面向C#开发者、逆向分析初学者及.NET平台调试人员,解决闭源DLL/EXE程序的代码理解、逻辑调试与轻量修改等核心需求。压缩包为zip格式,大小22.35MB,包含…

2026/10/9 18:03:28

Postman环境安装配置与Newman测试报告生成实战指南

简介:面向接口测试初学者与需要搭建持续集成测试环境的人员,这份PDF操作手册完整梳理了Postman安装、Newman及报告插件的部署流程。从官网下载、注册登录到环境校验,围绕在线安装与离线安装两条路径给出明确命令与可复现步骤,并针…

2026/10/9 17:58:27

Coze工作流自动生成功能测试用例,并驱动Playwright脚本实践

一直以为测试用例只能靠人肉一条条写,直到我把 Coze 工作流接上需求文档,生成效率和用例覆盖度直接提升了一大截。这篇文章就聊聊我搭的一套“Coze 自动生成测试用例”工作流:它怎么拆解需求、按测试设计方法自动产出功能测试用例&#xff0c…

2026/10/8 10:03:18

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/8 10:03:20

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/8 6:05:44

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 0:04:27

毕业论文初稿完成后首次进行AIGC疑似度自查的摸底与分流策略

毕业论文初稿完成后首次进行AIGC疑似度自查的摸底与分流策略当数万字的学位论文初稿经历开题、实验、问卷与多轮文献梳理最终成形时,绝大多数研究生都会面临一道全新的形式审查关卡:AIGC 疑似度排查。在高校毕业审核流程中,盲审前的文本检测通…

2026/10/9 0:04:27

食堂节能改造源头工厂,商用厨房设备焕新方案广受好评

商用厨房作为餐饮经营、单位供餐的核心后勤阵地,其设备配置、动线规划与运维体系直接决定后厨作业效率、运营成本与合规性。从基础的灶具、制冷存储设备,到油烟净化、水处理等配套系统,每一个环节的合理性都与食品安全、能耗管控、消防安全挂…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑