自定义一个仿拼多多地址选择器

news2024/11/17 13:41:28

前言

做了一个仿拼多多的地址选择器,但是与拼多多实现方法有些出入,大体效果是差不多的。废话不多说,先上一张效果动图:

img

开始

  1. 先说说本文的一些概念。地区级别:就是比如省级,市级,县级,镇级,那么这种最多就是4级。
  2. 好了,我们分析一波效果图,当一个级别的地区选择好之后会创建出一个新的Tab,到了最后一个地区级别之后就不会再创建新的。如果倒回去重新选择一个级别的地区,会移除后面的Tab之后再创建一个新的Tab。选择好之后,如果点击Tab会切换到相应地区级别,并且滚动到之前选择的地区显示,创建新的Tab就默认滚动到第一个position的位置。
  3. 其次,来看看我们这个界面的布局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="560dp"
   android:orientation="vertical"
   android:paddingStart="12dp"
   android:paddingEnd="12dp">
   <!-- Dialog的标题 -->
   <TextView
       android:id="@+id/user_tv_dialog_title"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="18dp"
       android:layout_gravity="center_horizontal"/>
   <!-- 标题下的第一条横线 -->
   <View
       android:layout_width="match_parent"
       android:layout_height="1dp"
       android:background="#e6e6e6"
       android:layout_marginTop="17dp"/>
   <!-- 顶部的TabLayout -->
   <android.support.design.widget.TabLayout
       android:id="@+id/user_tb_dialog_tab"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:tabSelectedTextColor="@color/colorPrimary"
       app:tabGravity="fill"
       app:tabMode="scrollable"/>
   <!-- TabLayout下方的横线 -->
   <View
       android:layout_width="match_parent"
       android:layout_height="1dp"
       android:background="#e6e6e6"/>
   <!-- 显示地区数据的RecyclerView -->
   <android.support.v7.widget.RecyclerView
       android:id="@+id/user_rv_dialog_list"
       android:layout_width="match_parent"
       android:layout_height="0dp"
       android:layout_weight="1"/>
</LinearLayout>
  1. 从布局中我们可以看出,我最主要靠TabLayout加RecyclerView实现这个效果,而拼多多个人猜测是TabLayout加RecyclerView加ViewPager,所以拼多多的RecyclerView是可以侧滑到上一个Tab页或下一个,这也就是和拼多多效果的不同之处。

开始撸代码

  1. 从代码下手,首先把单个地区列表的布局写好:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:paddingTop="10dp"
    android:paddingBottom="10dp"
    tools:ignore="UseCompoundDrawables">
    <!-- 显示地区名称 -->
    <TextView
        android:id="@+id/user_tv_address_dialog"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <!-- 显示后面的勾选图标 -->
    <ImageView
        android:id="@+id/user_iv_address_dialog"
        android:layout_width="13dp"
        android:layout_height="9dp"
        android:src="@drawable/user_icon_address_check"
        android:layout_marginStart="11dp"
        android:layout_gravity="center_vertical"
        android:visibility="gone"
        tools:ignore="ContentDescription" />
</LinearLayout>
  1. 把地区这个实体对象创建好:
public class AddressItem {
    // 地区名
    private String address;
    // 是否勾选
    private boolean isChecked;
    // 地区的ID,我这边项目需要的是int型,大家可以根据自己项目需要进行修改
    private int id;

    public String getAddress() {
        return this.address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public boolean isChecked() {
        return this.isChecked;
    }

    public void setChecked(boolean checked) {
        this.isChecked = checked;
    }

    public int getId() {
        return this.id;
    }

    public void setId(int id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "AddressItem{" +
                "address='" + address + '\'' +
                ", isChecked=" + isChecked +
                ", id=" + id +
                '}';
    }
}
  1. 把RecyclerView的适配器写好:
public class AddressAdapter extends RecyclerView.Adapter<AddressAdapter.MyViewHolder> {
    // 保存地区数据的列表
    private List<AddressItem> list = new ArrayList<>();
    // 自定义的单项被点击监听事件
    private ItemClickListener listener;

    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
        View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.user_item_address_bottom_sheet_dialog, viewGroup, false);
        return new MyViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull MyViewHolder myViewHolder, int i) {
        AddressItem item = list.get(i);
        if (item.isChecked()) {
            myViewHolder.tvAddress.setText(item.getAddress());
            myViewHolder.tvAddress.setTextColor(Color.parseColor("#1F83FF"));
            myViewHolder.ivChecked.setVisibility(View.VISIBLE);
        } else {
            myViewHolder.tvAddress.setText(item.getAddress());
            myViewHolder.tvAddress.setTextColor(Color.BLACK);
            myViewHolder.ivChecked.setVisibility(View.GONE);
        }
    }

    @Override
    public int getItemCount() {
        return this.list == null ? 0 : list.size();
    }

    public void setList(List<AddressItem> list) {
        if (this.list != null && list != null) {
            this.list.clear();
            this.list.addAll(list);
            this.notifyDataSetChanged();
        }
    }

    public void setOnItemClickListener(@NonNull ItemClickListener listener) {
        this.listener = listener;
    }

    class MyViewHolder extends RecyclerView.ViewHolder {
        TextView tvAddress;
        ImageView ivChecked;
        MyViewHolder(@NonNull View itemView) {
            super(itemView);
            tvAddress = itemView.findViewById(R.id.user_tv_address_dialog);
            ivChecked = itemView.findViewById(R.id.user_iv_address_dialog);
            if (listener != null) {
                itemView.setOnClickListener(v -> listener.onItemClick(getAdapterPosition()));
            }
        }
    }

    public interface ItemClickListener {
        void onItemClick(int position);
    }
}
  1. 首先自己动手写了两个BaseDialog,没什么营养,代码也很简单:
public abstract class CustomBaseDialog extends Dialog {

    protected Context context;

    public CustomBaseDialog(@NonNull Context context) {
        super(context);
        this.context = context;
    }

    protected abstract Integer getLayout();
    protected abstract Integer getGravity();
    protected abstract Integer getBackgroundRes();
    protected abstract Integer getWindowAnimations();
    protected abstract void initView();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (getLayout() != null)
            setContentView(getLayout());
        Window window = getWindow();
        if (window != null) {
            // 去除DecorView默认的内边距,好让布局占满整个横向屏幕
            View decorView = window.getDecorView();
            decorView.setPadding(0,0,0,0);
            if (getGravity() != null)
                window.setGravity(getGravity());
            else
                window.setGravity(Gravity.CENTER);
            if (getWindowAnimations() != null)
                window.setWindowAnimations(getWindowAnimations());
            if (getBackgroundRes() != null)
                decorView.setBackgroundResource(getBackgroundRes());
        }
        initView();
    }

    protected void setClickListener(int id, View.OnClickListener listener) {
        findViewById(id).setOnClickListener(listener);
    }
}

public abstract class CustomBaseBottomSheetDialog extends CustomBaseDialog {
    public CustomBaseBottomSheetDialog(@NonNull Context context) {
        super(context);
    }

    @Override
    protected Integer getGravity() {
        return Gravity.BOTTOM;
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Window window = getWindow();
        if (null != window) {
            // 去除window的margin,目的也是为了让布局占满屏幕
            WindowManager.LayoutParams layoutParams = window.getAttributes();
            layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT;
            layoutParams.horizontalMargin = 0;
            window.setAttributes(layoutParams);
        }
    }
}
  1. 接着才是重点,自定义地址选择器Dialog:
public class AddressBottomSheetDialog extends CustomBaseBottomSheetDialog {

    private TabLayout tabLayout;
    private AddressAdapter addressAdapter;

    private int maxLevel;   // 最大有多少级的地区,可以通过setMaxLevel方法进行自定义
    private SparseArray<List<AddressItem>> levelList;     // 级别列表数据
    private SparseIntArray levelPosition;                 // 各个级别选中的列表position
    private SparseIntArray levelIds;                      // 各个级别选择的地址ID
    private String title;  // 标题
    private String tabText = "请选择";                    // 新的Tab默认显示的文本
    private TabSelectChangeListener changeListener;       // Tab的选择被改变的监听

    public AddressBottomSheetDialog(@NonNull Context context) {
        super(context);
    }

    @Override
    protected Integer getLayout() {
        return R.layout.user_layout_address_bottom_sheet_dialog;
    }

    @Override
    protected Integer getBackgroundRes() {
        return R.drawable.bg_dialog_bottom;
    }

    @Override
    protected Integer getWindowAnimations() {
        return R.style.DialogBottom;
    }

    @Override
    protected void initView() {
        levelList = new SparseArray<>();
        levelPosition = new SparseIntArray();
        levelIds = new SparseIntArray();

        ((TextView)findViewById(R.id.user_tv_dialog_title)).setText(title);
        tabLayout = findViewById(R.id.user_tb_dialog_tab);
        final RecyclerView recyclerView = findViewById(R.id.user_rv_dialog_list);

        tabLayout.addOnTabSelectedListener(new TabLayout.BaseOnTabSelectedListener() {
            @Override
            public void onTabSelected(TabLayout.Tab tab) {
                final int position = tab.getPosition();
                List<AddressItem> list = levelList.get(position);
                if (null != list && !list.isEmpty()) {   // 如果选中级别的List没有数据就通过执行回调来获取,否则直接复用
                    addressAdapter.setList(list);
                    final int lastClickPositon = levelPosition.get(position, -1); // 获取上一次选中的地区的position,如果找不到,默认返回-1
                    if (lastClickPositon >= 0) recyclerView.smoothScrollToPosition(lastClickPositon); // 如果上一次有选择,RecyclerView滚动到指定position
                } else if (changeListener != null) {
                    changeListener.onSelectChange(position, levelIds.get(position));
                }
            }
            @Override
            public void onTabUnselected(TabLayout.Tab tab) {}
            @Override
            public void onTabReselected(TabLayout.Tab tab) {}
        });
        addressAdapter = new AddressAdapter();
        // 列表单项点击事件
        addressAdapter.setOnItemClickListener(position -> {
            final int selectedTabPosition = tabLayout.getSelectedTabPosition(); // 选中的Tab的position
            levelIds.put(selectedTabPosition, levelList.get(selectedTabPosition).get(position).getId()); // 更新选中的地区的ID
            changeSelect(selectedTabPosition, position);
            levelPosition.put(selectedTabPosition, position); // 更新选中的地区在列表中的position
            setTabText(selectedTabPosition, levelList.get(selectedTabPosition).get(position).getAddress()); // 将选中的地区的名字显示在Tab上
            if (selectedTabPosition < maxLevel - 1 && selectedTabPosition == tabLayout.getTabCount() - 1) { // 如果没达到MaxLevel并且选中的Tab是最后一个就添加一个Tab,并且RecyclerView滚动到最顶部
                tabLayout.addTab(createTab(), true);
                recyclerView.smoothScrollToPosition(0);
            }
        });
        recyclerView.setLayoutManager(new LinearLayoutManager(context));
        recyclerView.setAdapter(addressAdapter);
        tabLayout.addTab(createTab(), true); // 默认添加一个Tab
    }

    // 创建一个请选择的tab并返回
    private TabLayout.Tab createTab() {
        return tabLayout.newTab().setText(tabText);
    }

    // 当点击了RecyclerView条目的时候执行的方法
    private void changeSelect(int selectedTabPosition, int nowClickPosition) {
        // 保存下来的当前列表上一个点击位置.如果找不到该值,默认返回-1
        final int lastPosition = levelPosition.get(selectedTabPosition, -1);
        // 如果上一个点击位置和下一个点击位置相同,则不做改变
        if (nowClickPosition == lastPosition) {
            return;
        }
        // 如果不是最后一个并且又重新选择了级别地区,移除后面的Tab
        final int count = tabLayout.getTabCount();
        // 这里要倒过来移除Tab,不然会出现这样的情况,假如你有四个Tab,你移除第0个,接着移除第一个的话,第一个不是原来的第一个。因为你把第0个移除,原来的第一个就到了第0个的位置上。所以倒过来移除是明智的做法
        if (selectedTabPosition < count - 1) {
            TabLayout.Tab nowTab = tabLayout.getTabAt(selectedTabPosition);
            if (null != nowTab) nowTab.setText(tabText);
            for (int i = count - 1; i > selectedTabPosition; i--) {
                // 将相应地区级别的列表数据移除
                levelList.remove(i);
                // 将之前选中的position重置为-1
                levelPosition.put(i, -1);
                // 将之前记录的地区ID重置为-1
                levelIds.put(i, -1);
                tabLayout.removeTabAt(i);
            }
        }
        // 将现在选择的地区设置为已经选中
        levelList.get(selectedTabPosition).get(nowClickPosition).setChecked(true);
        // 通过adapter更新列表单个对象
        addressAdapter.notifyItemChanged(nowClickPosition);
        if (lastPosition >= 0) {
            // 将上一个选中的地区标记为未选中
            levelList.get(selectedTabPosition).get(lastPosition).setChecked(false);
            // 通过adapter更新列表单个对象
            addressAdapter.notifyItemChanged(lastPosition);
        }
    }
    // 设置第几个tab的文字
    private void setTabText(int tabPosition, String text) {
        TabLayout.Tab tab = tabLayout.getTabAt(tabPosition);
        if (null != tab) tab.setText(text);
    }

    // -----------------------------  以下是对外公开方法与接口  --------------------------

    /**
     *  设置Dialog的标题
     * @param title 标题文字
     */
    public void setDialogTitle(String title) {
        this.title = title;
    }

    /**
     *  设置在当前tab下还未选择区域时候tab默认显示的文字
     * @param tabDefaultText 默认显示的文字
     */
    public void setTabDefaultText(String tabDefaultText) {
        this.tabText = tabDefaultText;
    }

    /**
     *  设置地址最大级别(如:省,市,县,镇的话就是最大4级)
     * @param level 最大级别
     */
    public void setMaxLevel(int level) {
        this.maxLevel = level;
    }

    /**
     *  设置当前级别列表需要显示的列表数据
     * @param list 列表数据
     * @param level 地区级别
     */
    public void setCurrentAddressList(List<AddressItem> list, int level) {
        levelList.put(level, list);
        addressAdapter.setList(list);
    }

    /**
     *  设置Dialog中Tab点击切换的监听
     * @param listener tab切换监听实现
     */
    public void setTabSelectChangeListener(@NonNull TabSelectChangeListener listener) {
        this.changeListener = listener;
    }

    /**
     *  自定义的Tab切换监听接口
     */
    public interface TabSelectChangeListener {
        void onSelectChange(int level, int parentId);
    }
}
  1. 使用方法:
private void init() {
    mDialog = new AddressBottomSheetDialog(this);
    mDialog.setDialogTitle("配送至");
    mDialog.setMaxLevel(4);
    mDialog.setTabDefaultText("请选择");
    mDialog.setTabSelectChangeListener((level, parentId) ->
            mDialog.setCurrentAddressList(requestAddress(level, parentId), level)
    );
    binding.userIvSelectAddress.setOnClickListener(v -> mDialog.show());
}
private List<AddressItem> requestAddress(int level, int parentID) {
    List<AddressItem> list = new ArrayList<>();
    String levelTxt = "未知";
    switch (level) {
        case 0:
            levelTxt = "省级";
            break;
        case 1:
            levelTxt = "市级";
            break;
        case 2:
            levelTxt = "县级";
            break;
        case 3:
            levelTxt = "镇级";
    }
    for (int i = 0; i < 32; i++) {
        AddressItem item = new AddressItem();
        item.setChecked(false);
        item.setAddress(levelTxt + i);
        list.add(item);
    }
    return list;
}  

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/814371.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

理解复杂系统的关键:耐心

理解复杂系统的关键&#xff1a;耐心 复杂系统本质上是多面的、复杂的。它们通常并非被设计成一瞬间就能理解的。这对于自然发生的系统&#xff0c;如生态系统&#xff0c;和人类设计的系统&#xff0c;如高级软件或机械&#xff0c;都是适用的。这些系统是由多个组件混合而成…

【腾讯云Cloud Studio实战训练营】使用Cloud Studio迅捷开发一个3D家具个性化定制应用

目录 前言&#xff1a; 一、腾讯云 Cloud Studio介绍&#xff1a; 1、接近本地 IDE 的开发体验 2、多环境可选&#xff0c;或连接到云主机 3、随时分享预览效果 4、兼容 VSCode 插件 5、 AI代码助手 二、腾讯云Cloud Studio项目实践&#xff08;3D家具个性化定制应用&…

牛客网Verilog刷题——VL42

牛客网Verilog刷题——VL42 题目答案 题目 请设计一个可以实现任意小数分频的时钟分频器&#xff0c;比如说8.7分频的时钟信号&#xff0c;注意rst为低电平复位。提示&#xff1a;其实本质上是一个简单的数学问题&#xff0c;即如何使用最小公倍数得到时钟周期的分别频比。设小…

一夜卷走220万美元

* * * 原创&#xff1a;刘教链 * * * 号外&#xff1a;今天在「刘教链内参」发表了《内参&#xff1a;对传国内某DAO组织被刑事立案一事的分析》&#xff0c;欢迎关注公众号「刘教链内参」并阅读。 --- 隔夜比特币继续在29k上方盘旋。 表面的百无聊赖之下&#xff0c;各种土狗…

隐私保护之隐私信息检索

【引子】用户的隐私保护涉及多个方面&#xff0c;用户行为的隐私保护更是一个难点。周末读了一篇论文&#xff0c;https://cacm.acm.org/magazines/2010/4/81501-private-information-retrieval/fulltext&#xff0c;涉及了很多数学上的方法和概念&#xff0c;很是费劲&#xf…

Python--matplotlib基础绘图

前言 本章来说一下绘图&#xff0c;毕竟在软硬件行业&#xff0c;设备端的日志有了&#xff0c;前面也讲了抽取数据&#xff0c;怎么能不绘图呢。 在工作中&#xff0c;我也是经常会遇到研发有这样的需求&#xff0c;把数据整理出来&#xff0c;做成图&#xff0c;便于分析BUG。…

类变量和类方法

类变量和类方法 引入 思考&#xff1a;有一群小孩在玩堆雪人&#xff0c;不时有新的小孩加入&#xff0c;请问如何知道现在有多少小孩在玩&#xff1f; 静态变量内存分析 1、静态变量被对象共享 2、静态变量可能在堆中&#xff0c;也可能在方法区的静态域中&#xff0c;这…

Python中的类和实例是什么意思

目录 python中的类是什么 python中的实例是什么 python中的类和实例有什么区别 总结 python中的类是什么 在Python中&#xff0c;类是一种用于创建对象的蓝图或模板。类定义了对象的属性和方法。对象是类的实例&#xff0c;它拥有类定义的属性和方法。 通过定义类&#xff…

【Ajax】笔记-JQuery发送jsonp请求

前端 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>jQuery-jsonp</title><style>#re…

Linux安装操作(Mac版本)

Parallels Desktop的简介 Parallels Desktop是Mac平台上的虚拟机软件&#xff0c;也是Mac平台最好的虚拟机软件之一。它允许用户在Mac OS X系统上同时运行其他操作系统&#xff0c;例如Windows、Linux等。Parallels Desktop为Mac用户提供了使用其他操作系统和软件的便利性&…

如何在Delphi中用CnPack的魔法,快速搭建你的项目结构(cnwizard)

1、打开工具&#xff0c;如下图。 2、使用现有模板创建文件夹结构。 按照树状结构创建文件夹结构&#xff1a; 1&#xff09;打开窗体。点击第一个图标。 2&#xff09;选择要创建文件夹结构的文件夹。 3&#xff09;结果如下&#xff1a; 3、模仿指定的一个文件夹结构创建。…

春秋云镜 CVE-2021-34257

春秋云镜 CVE-2021-34257 WPanel4-CMS Authenticated RCE漏洞 靶标介绍 WPanel是一个用于构建博客、网站和网络应用程序的CMS。 WPanel 4 4.3.1 及更低版本存在安全漏洞&#xff0c;该漏洞源于通过恶意 PHP 文件上传到 (1) 仪表板的头像图像、(2) 帖子文件夹图像、(3) 页面文…

简单理解 ChatGPT 和模型训练

介绍 这些令人着迷的对话机器人使用自然语言理解来理解输入。NLU 是自然语言处理的一个子集&#xff0c;使机器能够理解自然语言&#xff08;文本/音频&#xff09;。NLU 是大多数 NLP 应用程序&#xff08;例如机器翻译、语音识别、构建聊天机器人等&#xff09;中的关键组件。…

【Docker】Docker安装Elasticsearch服务的正确方式

文章目录 1. 什么是Elasticsearch2. Docker安装Elasticsearch2.1 确定Elasticsearch的版本2.2. Docker安装Elasticsearch2.3. 给Elasticsearch安装中文分词器IKAnalyzer&#xff08;可选的&#xff09; Docker安装MySQL、Redis、RabbitMQ、Elasticsearch、Nacos等常见服务全套&…

无门槛使用GPT+Cloud Studio辅助编程完成Excel自动工资结算

目录 前言一、Cloud Studio产品介绍1.1 注册Cloud Studio 二、项目实验2.1 选择合适的开发环境2.2 实验项目介绍2.3 实验步骤 前言 chatgpt简单介绍: ChatGPT是一种基于GPT的自然语言处理模型&#xff0c;专门用于生成对话式文本。它是OpenAI于2021年发布的&#xff0c;在广泛…

移动端购物车模块设计

效果图 技术栈 vue3、vant4、element-plus 源码如下 页面布局 <template><!-- 地址 start--><AddressList class"address"/><!-- 地址 end--><!-- 购物车商品列表 start--><van-swipe-cell class"goods-cell" v-for…

DevOps系列文章之 java调用python脚本

在java类中直接执行python语句 在java类中直接调用本地python脚本 使用Runtime.getRuntime()执行python脚本文件&#xff08;推荐&#xff09; 调用python脚本中的函数 简单介绍 官网地址 首页 | (jython.org) Jython项目提供了Java中的Python实现&#xff0c; 为Python提供了…

ffplay——QT项目移植

一、ffmpeg源码编译 参考&#xff1a; https://blog.csdn.net/sgzed/article/details/119850119 在生成时做了一些修改&#xff1a; ./configure --toolchainmsvc --enable-shared --enable-postproc --enable-gpl --prefixwindows 二、对文件做调整 ffplay只需要三个文件&…

超越极限!YOLOv5引入FasterNet主干网络,目标检测速度飙升

目录 一、背景介绍1.1 目标检测算法简介1.2 YOLOv5简介及发展历程 二、主干网络选择的重要性2.1 主干网络在目标检测中的作用2.2 YOLOv5使用的默认主干网络 三、FasterNet简介与原理解析3.1 FasterNet概述3.2 FasterNet的网络结构3.2.1 基础网络模块3.2.2 快速特征融合模块3.2.…

好用的备忘录app如何使用预设提醒功能?

备忘录的预设提醒功能是什么意思呢&#xff1f;就是在使用的过程中&#xff0c;提前预设好常用的提醒的时间&#xff0c;比如明天某个时间点、下周某个时间点等等&#xff0c;在需要设置提醒的时候&#xff0c;就可以直接使用。好用的备忘录app如何使用预设提醒功能&#xff1f…