Flutter交互实战-即刻App探索页下拉&拖拽效果

栏目: IOS · Android · 发布时间: 5年前

内容简介:Flutter最近比较热门,但是Flutter成体系的文章并不多,前期避免不了踩坑;我这篇文章主要介绍如何使用Flutter实现一个比较复杂的手势交互,顺便分享一下我在使用Flutter过程中遇到的一些小坑,减少大家入坑;对了,顺便分享一下生成看文章的小伙伴最好能手持即刻App,亲自体验一下探索页的交互,是黄色Logo黄色主题色的即刻;有人简称‘黄即’;

Flutter最近比较热门,但是Flutter成体系的文章并不多,前期避免不了踩坑;我这篇文章主要介绍如何使用Flutter实现一个比较复杂的手势交互,顺便分享一下我在使用Flutter过程中遇到的一些小坑,减少大家入坑;

先睹为快

本项目支持ios&android运行,效果如下

Flutter交互实战-即刻App探索页下拉&拖拽效果 Flutter交互实战-即刻App探索页下拉&拖拽效果 Flutter交互实战-即刻App探索页下拉&拖拽效果 Flutter交互实战-即刻App探索页下拉&拖拽效果

对了,顺便分享一下生成 gif 的小窍门,建议用手机自带录屏功能导出 mp4 文件到电脑,然后电脑端用 ffmpeg 命令行处理,控制 gif 的质量和文件大小,我的建议是分辨率控制在270p,帧率在10左右;

交互分析

看文章的小伙伴最好能手持即刻App,亲自体验一下探索页的交互,是黄色Logo黄色主题色的即刻;有人简称‘黄即’;

Flutter交互实战-即刻App探索页下拉&拖拽效果

即刻App原版功能有卡片旋转,卡片撤回和卡片自动移除,时间关系我暂时没有去实现,但是核心功能一点都不会砍;

以我Android开发习惯来看,交互分为可拆分内外两层,外层我们需要一个整体下拉的控件,内层我们需要实现一个上、下、左、右四方向拖拽移动的控件,我们称为卡片控件;同时这两层还需要处理子Widget的布局,再看细节:

下拉控件:

  • 子控件从上到下竖直摆放,顶部菜单默认隐藏在屏幕外
  • 下拉手势所有子控件下移,菜单视觉差效果
  • 支持点击自动展开、收起效果

卡片控件

  • 卡片层叠布局,错落有致
  • 最上层卡片支持手势拖拽
  • 其他卡片相应拖拽小幅位移
  • 松手移除卡片

码上入手

热身

套用App开发伎俩,实现上面的交互无非就是控件布局和手势识别。当然在Flutter中也跑不掉这两点,在Flutter中常用的基本布局有 ColumnRowStack 等,手势识别有 ListenerGestureDetectorRawGestureDetector 等,本文的讲解不限于上面这几个Widget,因为Flutter提供的Widget太多了,真是用到啥查啥;

所以下面我们从布局和手势这两个大的技术点,来一一击破功能点;

布局摆放

这里所谓的布局,包括Widget的尺寸大小和位置的控制,一般都是父Widget掌管子Widget的命运,Flutter就是一层一层Widget嵌套,不要担心,下面从外到内具体案例讲解;

下拉控件

首先我们要实现最外层布局,效果是:子Widget竖直摆放,且最上面的Widget默认需要摆放在屏幕外;

Flutter交互实战-即刻App探索页下拉&拖拽效果

如上图所示,红色区域是屏幕范围, header 是头部隐藏的菜单布局, content 是卡片布局的主体;

先说入的坑

竖直布局我最先想到的是 Column ,我想要的效果是 content 高度和父Widget的高度一致,我首先想到是让 Expanded 包裹 content ,结果是content的高度永远等于 Column 高度减 header 高度,造成现象就是content高度不填充,或者是挤压现象,如果继续使用 Colunm 可能就得放弃 Expanded ,手动给 content 赋值高度,没准是个办法,但我不愿意手动赋值 content ,我不想为了实现而实现,果断放弃用 Column

另一个问题是如何隐藏 header ,我想到两种方案:

  1. 采用外层 Transform 包裹整个布局,内层 Transform 包裹 header ,然后赋值内层 dy = -headerHeight ,随着手势下拉动态,并不改变 headerTransform ,而是改变最外层 Transformdy
  2. 动态改变 header 高度,初始高度为0,随着手势下拉动态计算;

但是上面这两种都有坑,第一种方式会影响控件的点击事件, onTap 方法不会被回调;第二种由于高度在不断改变,会影象 header 内部子Widget的布局,很难做视觉差的控制;

最终方案

最后采用 Stack 来布局,通过 Stack 配合 Positioned ,实现 header 布局在屏幕外,而且可以做到让 content 布局填充父Widget;

PullDragWidget

Widget build(BuildContext context) {
  return RawGestureDetector(
      behavior: HitTestBehavior.translucent,
      gestures: _contentGestures,
      child: Stack(
        children: <Widget>[
          Positioned(//content布局
              top: _offsetY,
              bottom: -_offsetY,
              left: 0,
              right: 0,
              child: IgnorePointer(
                ignoring: _opened,
                child: widget.child,
              )),
          Positioned(////header布局
              top: -widget.dragHeight + _offsetY,
              bottom: null,
              left: 0,
              right: 0,
              height: widget.dragHeight,
              child: _headerWidget()),
        ],
      ));
}
复制代码

首先解释一下 Positioned 的基本用法, topbottomheight 控制高度和位置,而且两两配合使用, topbottom 可以理解成marginTop和marginBottom, height 顾名思义是直接Widget的高度,如果 top 配置 bottom ,意味着高度等于 parentHeight-top-bottom ,如果 top / bottom 配合 height 使用,高度一般是固定的,当然 topbottom 是接受负数的;

再分析代码,首先 _offsetY 是下拉距离,是一个改变的量初始值为0, content 需要设置 top = _offsetYbottom = -_offsetY ,改变的是上下位置,高度不会改变;同理, header 是采用 topheight 控制,高度固定,只需要动态改变 top 即可;

用Flutter写布局真的很简单,我极力推崇使用 Stack 布局,因为它比较灵活,没有太多的限制,用好 Stack 主要还得用好 Positioned ,学好它没错;

卡片控件

卡片实现的效果就是依次层叠,错落有致,这个很容易想到 Stack 来实现,当然有了上面踩坑,用 Stack 算是很轻松了;

Flutter交互实战-即刻App探索页下拉&拖拽效果

重叠的效果使用Stack很简单,错落有致的效果实在起来可能性就比较多了,比如可以使用 Positioned ,也可以包裹 Container 改变 margin 或者 padding ,但是考虑到角度的旋转,我选择使用 Transform ,因为 Transform 不仅可以玩转位移,还有角度和缩放等,其实就是一个矩阵变换;but但是我对 Transform 持有疑问:执行完变换之后,有某些情况是不能正常的相应触摸事件,这可能是 Transform 的bug;

CardStackWidget

Widget build(BuildContext context) {
  if (widget.cardList == null || widget.cardList.length == 0) {
    return Container();
  }
  List<Widget> children = new List();
  int length = widget.cardList.length;
  int count = (length > widget.cardCount) ? widget.cardCount : length;
  for (int i = 0; i < count; i++) {
    double dx = i == 0 ? _totalDx : -_ratio * widget.offset;
    double dy = i == 0 ? _totalDy : _ratio * widget.offset;
    Widget cardWidget = _CardWidget(
      cardEntity: widget.cardList[i],
      position: i,
      dx: dx,
      dy: dy,
      offset: widget.offset,
    );
    if (i == 0) {
      cardWidget = RawGestureDetector(
        gestures: _cardGestures,
        behavior: HitTestBehavior.deferToChild,
        child: cardWidget,
      );
    }
    children.add(Container(
      child: cardWidget,
      alignment: Alignment.topCenter,
      padding: widget.cardPadding,
    ));
  }
  return Stack(
    children: children.reversed.toList(),
  );
}
复制代码

_CardWidget

Widget build(BuildContext context) {
  return AspectRatio(
    aspectRatio: 0.75,
    child: Transform(
        transform: Matrix4.translationValues(
            dx + (offset * position.toDouble()),
            dy + (-offset * position.toDouble()),
            0),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(10),
          child: Stack(
            fit: StackFit.expand,
            children: <Widget>[
              Image.network(
                cardEntity.picUrl,
                fit: BoxFit.cover,
              ),
              Container(color: const Color(0x5a000000)),
              Container(
                margin: EdgeInsets.all(20),
                alignment: Alignment.center,
                child: Text(
                  cardEntity.text,
                  textAlign: TextAlign.center,
                  style: TextStyle(
                      letterSpacing: 2,
                      fontSize: 22,
                      color: Colors.white,
                      fontWeight: FontWeight.bold),
                  maxLines: 4,
                ),
              )
            ],
          ),
        )),
  );
}
复制代码

简单总结一下卡片布局代码, CardStackWidget 是管理卡片 Stack 的父控件,负责对每个卡片进行布局, _CardWidget 是对单独卡片内部进行布局,总体来说没有什么难点,细节控制逻辑是在对上层 _CardWidget 和底层 _CardWidget 偏移量的计算;

布局的内容就讲这么多,整体来说还是比较简单,所谓的有些坑也不一定算是坑,只是不适应某些应用场景罢了;

手势识别

Flutter手势识别最常用的是 ListenerGestureDetector 这两个Widget,其中 Listener 主要针对原始触摸点进行处理, GestureDetector 已经对原始触摸点加工成了不同的手势;这两个类的方法介绍如下;

Listener

Listener({
  Key key,
  this.onPointerDown, //手指按下回调
  this.onPointerMove, //手指移动回调
  this.onPointerUp,//手指抬起回调
  this.onPointerCancel,//触摸事件取消回调
  this.behavior = HitTestBehavior.deferToChild, //在命中测试期间如何表现
  Widget child
})
复制代码

GestureDetector手势回调:

Property/Callback Description
onTapDown 用户每次和屏幕交互时都会被调用
onTapUp 用户停止触摸屏幕时触发
onTap 短暂触摸屏幕时触发
onTapCancel 用户触摸了屏幕,但是没有完成Tap的动作时触发
onDoubleTap 用户在短时间内触摸了屏幕两次
onLongPress 用户触摸屏幕时间超过500ms时触发
onVerticalDragDown 当一个触摸点开始跟屏幕交互,同时在垂直方向上移动时触发
onVerticalDragStart 当触摸点开始在垂直方向上移动时触发
onVerticalDragUpdate 屏幕上的触摸点位置每次改变时,都会触发这个回调
onVerticalDragEnd 当用户停止移动,这个拖拽操作就被认为是完成了,就会触发这个回调
onVerticalDragCancel 用户突然停止拖拽时触发
onHorizontalDragDown 当一个触摸点开始跟屏幕交互,同时在水平方向上移动时触发
onHorizontalDragStart 当触摸点开始在水平方向上移动时触发
onHorizontalDragUpdate 屏幕上的触摸点位置每次改变时,都会触发这个回调
onHorizontalDragEnd 水平拖拽结束时触发
onHorizontalDragCancel onHorizontalDragDown没有成功完成时触发
onPanDown 当触摸点开始跟屏幕交互时触发
onPanStart 当触摸点开始移动时触发
onPanUpdate 屏幕上的触摸点位置每次改变时,都会触发这个回调
onPanEnd pan操作完成时触发
onScaleStart 触摸点开始跟屏幕交互时触发,同时会建立一个焦点为1.0
onScaleUpdate 跟屏幕交互时触发,同时会标示一个新的焦点
onScaleEnd 触摸点不再跟屏幕有任何交互,同时也表示这个scale手势完成

ListenerGestureDetector 如何抉择,首先 GestureDetector 是基于 Listener 封装,它解决了大部分手势冲突,我们使用 GestureDetector 就够用了,但是 GestureDetector 不是万能的,必要时候需要自定义 RawGestureDetector

另外一个很重要的概念,Flutter手势事件是一个从内Widget向外Widget的冒泡机制,假设内外Widget同时监听竖直方向的拖拽事件 onVerticalDragUpdate ,往往都是内层控件获得事件,外层事件被动取消;这样的概念和Android父布局拦截机制就完全不同了;

虽然Flutter没有外层拦截机制,但是似乎还有一线希望,那就是 IgnorePointerAbsorbPointer Widget,这俩哥们可以忽略或者阻止子Widget树不响应Event;

手势分析

基本原理介绍完了,接下来分析案例交互,上面说了我把整体布局拆分成了下拉控件和卡片控件,分析即刻App的拖拽的行为:当下拉控件没有展开下拉菜单时,卡片控件是可以相应上、左、右三个方向的手势,下拉控件只相应一个向下方向的手势;当下拉菜单展开时,卡片不能相应任何手势,下拉控件可以相应竖直方向的所有事件;

Flutter交互实战-即刻App探索页下拉&拖拽效果

上图更加形象解释两种状态下的手势响应,下拉控件是父Widget,卡片控件是子Widget,由于子Widget能优先响手势,所以在初始阶段,我们不能让子Widget响应向下的手势;

由于 GestureDetector 只封装水平和竖直方向的手势,且两种手势不能同时使用,我们从 GestureDetector 源码来看,能不能封装一个监听不同四个方向的手势,;

GestureDetector

final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};

if (onVerticalDragDown != null ||
    onVerticalDragStart != null ||
    onVerticalDragUpdate != null ||
    onVerticalDragEnd != null ||
    onVerticalDragCancel != null) {
  gestures[VerticalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
    () => VerticalDragGestureRecognizer(debugOwner: this),
    (VerticalDragGestureRecognizer instance) {
      instance
        ..onDown = onVerticalDragDown
        ..onStart = onVerticalDragStart
        ..onUpdate = onVerticalDragUpdate
        ..onEnd = onVerticalDragEnd
        ..onCancel = onVerticalDragCancel;
    },
  );
}

return RawGestureDetector(
  gestures: gestures,
  behavior: behavior,
  excludeFromSemantics: excludeFromSemantics,
  child: child,
);
复制代码

GestureDetector 最终返回的是 RawGestureDetector ,其中 gestures 是一个 map ,竖直方向的手势在 VerticalDragGestureRecognizer 这个类;

VerticalDragGestureRecognizer

class VerticalDragGestureRecognizer extends DragGestureRecognizer {
  /// Create a gesture recognizer for interactions in the vertical axis.
  VerticalDragGestureRecognizer({ Object debugOwner }) : super(debugOwner: debugOwner);

  @override
  bool _isFlingGesture(VelocityEstimate estimate) {
    final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
    final double minDistance = minFlingDistance ?? kTouchSlop;
    return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance;
  }

  @override
  bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;

  @override
  Offset _getDeltaForDetails(Offset delta) => Offset(0.0, delta.dy);

  @override
  double _getPrimaryValueFromOffset(Offset value) => value.dy;

  @override
  String get debugDescription => 'vertical drag';
}
复制代码

VerticalDragGestureRecognizer 继承 DragGestureRecognizer ,大部分逻辑都在 DragGestureRecognizer 中,我们只关注重写的方法:

_hasSufficientPendingDragDeltaToAccept
_getDeltaForDetails
_getPrimaryValueFromOffset
_isFlingGesture

自定义DragGestureRecognizer

想实现接受三个方向的手势,自定义 DragGestureRecognizer 是一个好的思路;我希望接受上、下、左、右四个方向的参数,根据参数不同监听不同的手势行为,照葫芦画瓢自定义一个接受方向的 GestureRecognizer

DirectionGestureRecognizer

class DirectionGestureRecognizer extends _DragGestureRecognizer {
  int direction;
  //接受中途变动
  ChangeGestureDirection changeGestureDirection;
  //不同方向
  static int left = 1 << 1;
  static int right = 1 << 2;
  static int up = 1 << 3;
  static int down = 1 << 4;
  static int all = left | right | up | down;

  DirectionGestureRecognizer(this.direction,
      {Object debugOwner})
      : super(debugOwner: debugOwner);

  @override
  bool _isFlingGesture(VelocityEstimate estimate) {
    if (changeGestureDirection != null) {
      direction = changeGestureDirection();
    }
    final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
    final double minDistance = minFlingDistance ?? kTouchSlop;
    if (_hasAll) {
      return estimate.pixelsPerSecond.distanceSquared > minVelocity &&
          estimate.offset.distanceSquared > minDistance;
    } else {
      bool result = false;
      if (_hasVertical) {
        result |= estimate.pixelsPerSecond.dy.abs() > minVelocity &&
            estimate.offset.dy.abs() > minDistance;
      }
      if (_hasHorizontal) {
        result |= estimate.pixelsPerSecond.dx.abs() > minVelocity &&
            estimate.offset.dx.abs() > minDistance;
      }
      return result;
    }
  }

  bool get _hasLeft => _has(DirectionGestureRecognizer.left);

  bool get _hasRight => _has(DirectionGestureRecognizer.right);

  bool get _hasUp => _has(DirectionGestureRecognizer.up);

  bool get _hasDown => _has(DirectionGestureRecognizer.down);
  bool get _hasHorizontal => _hasLeft || _hasRight;
  bool get _hasVertical => _hasUp || _hasDown;

  bool get _hasAll => _hasLeft && _hasRight && _hasUp && _hasDown;

  bool _has(int flag) {
    return (direction & flag) != 0;
  }

  @override
  bool get _hasSufficientPendingDragDeltaToAccept {
    if (changeGestureDirection != null) {
      direction = changeGestureDirection();
    }
    // if (_hasAll) {
    //   return _pendingDragOffset.distance > kPanSlop;
    // }
    bool result = false;
    if (_hasUp) {
      result |= _pendingDragOffset.dy < -kTouchSlop;
    }
    if (_hasDown) {
      result |= _pendingDragOffset.dy > kTouchSlop;
    }
    if (_hasLeft) {
      result |= _pendingDragOffset.dx < -kTouchSlop;
    }
    if (_hasRight) {
      result |= _pendingDragOffset.dx > kTouchSlop;
    }
    return result;
  }

  @override
  Offset _getDeltaForDetails(Offset delta) {
    if (_hasAll || (_hasVertical && _hasHorizontal)) {
      return delta;
    }

    double dx = delta.dx;
    double dy = delta.dy;

    if (_hasVertical) {
      dx = 0;
    }
    if (_hasHorizontal) {
      dy = 0;
    }
    Offset offset = Offset(dx, dy);
    return offset;
  }

  @override
  double _getPrimaryValueFromOffset(Offset value) {
    return null;
  }

  @override
  String get debugDescription => 'orientation_' + direction.toString();
}
复制代码

重写主要的识别方法,根据不同的参数处理不同的手势逻辑;

注意事项

但是这里有一些注意事项: _getDeltaForDetails 返回水平竖直方向的偏移量,在手势交叉方向的偏移量适情况需要置0;

当前Widget树只纯在一个手势时,手势判断的逻辑 _hasSufficientPendingDragDeltaToAccept 可能不会被调用,一定要重写 _getDeltaForDetails 控制返回结果;

如何使用

自定义的 DirectionGestureRecognizer 可以配置 leftrightupdown 四个方向的手势,而且支持不同的组合;

比如我们只想监听竖直向下方向,就创建 DirectionGestureRecognizer(DirectionGestureRecognizer.down) 的手势识别;

想监听上、左、右的手势,创建 DirectionGestureRecognizer(DirectionGestureRecognizer.left | DirectionGestureRecognizer.right | DirectionGestureRecognizer.up) 的手势识别;

DirectionGestureRecognizer 就像一把磨刀石,刀已经磨锋利,砍材就很轻松了,下面进行控件的手势实现;

下拉控件手势

PullDragWidget

_contentGestures = {
//向下的手势
  DirectionGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>(
          () => DirectionGestureRecognizer(DirectionGestureRecognizer.down),
          (instance) {
    instance.onDown = _onDragDown;
    instance.onStart = _onDragStart;
    instance.onUpdate = _onDragUpdate;
    instance.onCancel = _onDragCancel;
    instance.onEnd = _onDragEnd;
  }),
  //点击的手势
  TapGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(), (instance) {
    instance.onTap = _onContentTap;
  })
};

Widget build(BuildContext context) {
  return RawGestureDetector(//返回RawGestureDetector
      behavior: HitTestBehavior.translucent,
      gestures: _contentGestures,//手势在此
      child: Stack(
        children: <Widget>[
          Positioned(
              top: _offsetY,
              bottom: -_offsetY,
              left: 0,
              right: 0,
              child: IgnorePointer(
                ignoring: _opened,
                child: widget.child,
              )),
          Positioned(
              top: -widget.dragHeight + _offsetY,
              bottom: null,
              left: 0,
              right: 0,
              height: widget.dragHeight,
              child: _headerWidget()),
        ],
      ));
}
复制代码

PullDragWidget 是下拉拖拽控件,根Widget是一个 RawGestureDetector 用来监听手势,其中 gestures 支持向下拖拽和点击两个手势;当下拉控件处于 _opened 状态说 header 已经拉下来,此时配合 IgnorePointer ,禁用子Widget所有的事件监听,自然内部的卡片就相应不了任何事件;

卡片控件手势

同下拉控件一样,卡片控件只需要监听其余三个方向的手势,即可完成任务:

CardStackWidget

_cardGestures = {
  DirectionGestureRecognizer://监听上左右三个方向
      GestureRecognizerFactoryWithHandlers<DirectionGestureRecognizer>(
          () => DirectionGestureRecognizer(DirectionGestureRecognizer.left |
              DirectionGestureRecognizer.right |
              DirectionGestureRecognizer.up), (instance) {
    instance.onDown = _onPanDown;
    instance.onUpdate = _onPanUpdate;
    instance.onEnd = _onPanEnd;
  }),
  TapGestureRecognizer:
      GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
          () => TapGestureRecognizer(), (instance) {
    instance.onTap = _onCardTap;
  })
};
复制代码

小结

根据Flutter手势冒泡的特性,父Widget既没有响应事件的优先权,也没有监听单独方向的手势,只能自己想办法自定义 GestureRecognizer ,把原本 VerticalHorizontal 两个方向的手势识别扩展成 leftrightupdown 四个方向,分开监听可能会冲突的手势;当然也可能有其他的方案来实现手势的监听,希望大家能提出宝贵意见;

总结

知识点

由于篇幅有限并没有完全介绍该交互的所有内容,我归纳一下代码中用到的知识点:

  • ColumnRowExpandedStackPositionedTransform 等Widget;
  • GestureDetectorRawGestureDetectorIgnorePointer 等Widget;
  • 自定义 GestureRecognizer 实现自定义手势识别;
  • AnimationControllerTween 等动画的使用;
  • EventBus 的使用;

最后

上面章节主要介绍在当前场景下用Flutter布局和手势的实战技巧,其中更深层次手势竞技和分发的源码级分析,有机会再做深入学习和分享;

另外本篇并不是循序渐进的零基础入门,对刚接触的同学可能感觉有点懵,但是没有关系,建议你 clone 一份代码跑起来效果,没准就能提起自己学习的兴趣;

最最后,本篇所有代码都是开源的,你的点赞是对我最大的鼓励。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

超级IP

超级IP

吴声 / 中信出版集团 / 2016-7 / 49.00元

一切商业皆内容,一切内容皆IP! 从迪士尼、airbnb、YouTube、Instagram到微信、Papi酱、芈月传、鹿晗,IP浪潮席卷全球,这不仅仅是互联网领域的革命,更是未来商业的游戏新规则。 IP从泛娱乐形态快速渗透新商业生态全维度,正深化为不同行业共同的战略方法,甚至是一种全新的商业生存方式,即IP化生存。 超级IP的内核,是辨识度极高的可认同的商业符号,它意味着一种对......一起来看看 《超级IP》 这本书的介绍吧!

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具